diff --git a/inc/native/class-wp-markdown-native-query-contracts.php b/inc/native/class-wp-markdown-native-query-contracts.php index 180b683..58b5481 100644 --- a/inc/native/class-wp-markdown-native-query-contracts.php +++ b/inc/native/class-wp-markdown-native-query-contracts.php @@ -571,8 +571,19 @@ interface WP_Markdown_Query_Runtime { public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result; } +/** Optional proof surface for callers that require atomic canonical table writes. */ +interface WP_Markdown_Native_Transactional_Table_Support { + /** @param string[] $tables */ + public function supports_transactional_tables( array $tables ): bool; +} + /** Providers supply validated rows without exposing storage to the executor. */ interface WP_Markdown_Native_Table_Provider { /** @return iterable>|WP_Markdown_Query_Result */ public function read( WP_Markdown_Native_Table_Access $access ): iterable|WP_Markdown_Query_Result; } + +/** A canonical provider exposes the factory-validated root it reads and writes. */ +interface WP_Markdown_Native_Canonical_Table_Provider extends WP_Markdown_Native_Table_Provider { + public function canonical_root(): string; +} diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index 48afb02..688164f 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -30,7 +30,7 @@ public function read( WP_Markdown_Native_Table_Access $access ): iterable|WP_Mar } } -final class WP_Markdown_Native_Query_Runtime implements WP_Markdown_Query_Runtime { +final class WP_Markdown_Native_Query_Runtime implements WP_Markdown_Query_Runtime, WP_Markdown_Native_Transactional_Table_Support { private const MAX_JOIN_CANDIDATE_PAIRS = 100000; private const MAX_CORRELATED_SUBQUERY_EVALUATIONS = 10000; /** The largest SQL request accepted by the native request boundary. */ @@ -90,6 +90,40 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query } } + /** + * Confirm that every exact table has a recognized canonical provider, its + * matching configured mutation runtime, and a factory-admitted journal root. + * This is an atomic-write guarantee, not an InnoDB or mysqli-session claim. + * + * @param string[] $tables + */ + public function supports_transactional_tables( array $tables ): bool { + if ( null === $this->transactions || array() === $tables ) { + return false; + } + + foreach ( $tables as $table_name ) { + if ( ! is_string( $table_name ) || 1 !== preg_match( '/^[A-Za-z_][A-Za-z0-9_]*$/D', $table_name ) || $this->registry->is_shadowed( $table_name ) ) { + return false; + } + $table = $this->registry->table( $table_name ); + if ( null === $table || ! $this->supports_transactional_provider( $table['provider'] ) ) { + return false; + } + } + + return true; + } + + private function supports_transactional_provider( WP_Markdown_Native_Table_Provider $provider ): bool { + if ( ! $provider instanceof WP_Markdown_Native_Canonical_Table_Provider || ! $this->transactions->covers_root( $provider->canonical_root() ) ) { + return false; + } + return ( $provider instanceof WP_Markdown_Native_Post_Provider && null !== $this->post_mutations ) + || ( $provider instanceof WP_Markdown_Native_Option_Provider && null !== $this->option_mutations ) + || ( $provider instanceof WP_Markdown_Native_JSON_Snapshot_Provider && null !== $this->table_mutations ); + } + private function execute_request( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result { self::trace_runtime_phase( 'executor', $request->sql() ); if ( strlen( $request->sql() ) > self::MAX_SQL_BYTES ) { @@ -108,17 +142,26 @@ private function execute_request( WP_Markdown_Query_Request $request ): WP_Markd if ( null !== $transaction_control ) { return $this->execute_transaction_control( $transaction_control ); } - $write_admitted = null !== $this->transactions && null !== WP_Markdown_SQL_Classifier::mutation( $request->sql() ); - if ( $write_admitted ) { + // Advisory locks have their own root-scoped lock files. Holding the + // transaction lock while waiting for one would invert their release order. + $mutation = null !== WP_Markdown_SQL_Classifier::mutation( $request->sql() ); + $canonical_admitted = null !== $this->transactions && ! $this->is_advisory_lock_statement( $request->sql() ); + if ( $canonical_admitted ) { + $transactional_view = $this->transactions->is_in_transaction(); $locked = $this->transactions->begin_write(); if ( true !== $locked ) { - return $this->failure( 'transaction_write_lock_failed', $locked ); + return $this->failure( $mutation ? 'transaction_write_lock_failed' : 'transaction_read_lock_failed', $locked ); + } + if ( ! $transactional_view || $this->transactions->waited_for_write_lock() ) { + // Autocommit requests start a fresh canonical view. A transaction that + // waited also cannot retain snapshots from before the prior commit. + $this->registry->forget_snapshots(); } } try { return $this->execute_unlocked_request( $request ); } finally { - if ( $write_admitted ) { + if ( $canonical_admitted ) { $this->transactions->finish_write(); } } @@ -354,6 +397,10 @@ private function advisory_lock_query( string $sql ): ?WP_Markdown_Query_Result { ); } + private function is_advisory_lock_statement( string $sql ): bool { + return 1 === preg_match( '/^\s*SELECT\s+(?:GET_LOCK|RELEASE_LOCK)\s*\(/i', $sql ); + } + private function execute_query_plan( WP_Markdown_Native_Query_Plan $plan, bool $allow_union = true ): WP_Markdown_Query_Result { $hint_error = $this->validate_index_hints( $plan ); if ( null !== $hint_error ) { @@ -2583,6 +2630,9 @@ private function execute_transaction_control( array $control ): WP_Markdown_Quer if ( true !== $outcome ) { return $this->failure( 'transaction_control_failed', $outcome ); } + if ( $this->transactions->waited_for_write_lock() || in_array( $control['action'], array( 'begin', 'autocommit_0' ), true ) ) { + $this->registry->forget_snapshots(); + } if ( 'commit_chain' === $control['action'] || 'rollback_chain' === $control['action'] ) { $chained = $this->transactions->begin(); if ( true !== $chained ) { diff --git a/inc/native/class-wp-markdown-native-query-runtime.php b/inc/native/class-wp-markdown-native-query-runtime.php index b005409..bb610b3 100644 --- a/inc/native/class-wp-markdown-native-query-runtime.php +++ b/inc/native/class-wp-markdown-native-query-runtime.php @@ -618,7 +618,7 @@ private static function all_ascii_strings( array $values ): bool { } } -final class WP_Markdown_Native_Option_Query_Runtime implements WP_Markdown_Query_Runtime { +final class WP_Markdown_Native_Option_Query_Runtime implements WP_Markdown_Query_Runtime, WP_Markdown_Native_Transactional_Table_Support { private WP_Markdown_Native_Query_Runtime $runtime; @@ -641,10 +641,14 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query } return $this->runtime->execute( $request ); } + + public function supports_transactional_tables( array $tables ): bool { + return $this->runtime->supports_transactional_tables( $tables ); + } } /** Lazily construct a single-root runtime for the prefix selected by wpdb. */ -final class WP_Markdown_Native_Prefix_Query_Runtime implements WP_Markdown_Query_Runtime { +final class WP_Markdown_Native_Prefix_Query_Runtime implements WP_Markdown_Query_Runtime, WP_Markdown_Native_Transactional_Table_Support { /** @var array */ private array $runtimes = array(); @@ -674,6 +678,22 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query return $this->runtimes[ $prefix ]->execute( $request ); } + public function supports_transactional_tables( array $tables ): bool { + $prefix = isset( $GLOBALS['wpdb']->prefix ) && is_string( $GLOBALS['wpdb']->prefix ) ? $GLOBALS['wpdb']->prefix : 'wp_'; + if ( ! isset( $this->runtimes[ $prefix ] ) ) { + $this->runtimes[ $prefix ] = WP_Markdown_Native_Runtime_Factory::runtime( + $this->state_root, + $prefix, + $prefix, + false, + $this->content_root, + advisory_locks: $this->advisory_locks, + session: $this->session + ); + } + return $this->runtimes[ $prefix ]->supports_transactional_tables( $tables ); + } + public function close(): void { $this->advisory_locks->close(); $this->session->reset(); @@ -681,7 +701,7 @@ public function close(): void { } /** Defer WordPress topology detection because db.php precedes multisite bootstrap. */ -final class WP_Markdown_Native_WordPress_Query_Runtime implements WP_Markdown_Query_Runtime { +final class WP_Markdown_Native_WordPress_Query_Runtime implements WP_Markdown_Query_Runtime, WP_Markdown_Native_Transactional_Table_Support { private WP_Markdown_Native_Prefix_Query_Runtime $prefix_runtime; /** @var array */ @@ -718,10 +738,22 @@ public function close(): void { $runtime->close(); } } + + public function supports_transactional_tables( array $tables ): bool { + $multisite = ( defined( 'WP_INSTALLING_NETWORK' ) && WP_INSTALLING_NETWORK ) || ( defined( 'MULTISITE' ) && MULTISITE ) || ( function_exists( 'is_multisite' ) && is_multisite() ); + if ( ! $multisite ) { + return $this->prefix_runtime->supports_transactional_tables( $tables ); + } + $base_prefix = isset( $GLOBALS['wpdb']->base_prefix ) && is_string( $GLOBALS['wpdb']->base_prefix ) ? $GLOBALS['wpdb']->base_prefix : $this->base_prefix; + if ( ! isset( $this->multisite_runtimes[ $base_prefix ] ) ) { + $this->multisite_runtimes[ $base_prefix ] = new WP_Markdown_Native_Multisite_Query_Runtime( $this->state_root, $base_prefix, $this->content_root, $this->session ); + } + return $this->multisite_runtimes[ $base_prefix ]->supports_transactional_tables( $tables ); + } } /** Lazily compose a native runtime for each WordPress multisite table scope. */ -final class WP_Markdown_Native_Multisite_Query_Runtime implements WP_Markdown_Query_Runtime { +final class WP_Markdown_Native_Multisite_Query_Runtime implements WP_Markdown_Query_Runtime, WP_Markdown_Native_Transactional_Table_Support { /** @var array */ private array $runtimes = array(); @@ -791,6 +823,27 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query return $this->runtimes[ $prefix ]->execute( $request ); } + public function supports_transactional_tables( array $tables ): bool { + $prefix = isset( $GLOBALS['wpdb']->prefix ) && is_string( $GLOBALS['wpdb']->prefix ) ? $GLOBALS['wpdb']->prefix : $this->base_prefix; + if ( ! $this->is_scope_prefix( $prefix ) ) { + return false; + } + if ( ! isset( $this->runtimes[ $prefix ] ) ) { + $roots = $this->roots( $prefix ); + if ( null === $roots ) { + return false; + } + try { + $this->runtimes[ $prefix ] = WP_Markdown_Native_Runtime_Factory::runtime( + $roots['state'], $prefix, $this->base_prefix, true, $roots['content'], $this->state_root, $this->content_root, $this->advisory_locks, $this->state_root, $this->session + ); + } catch ( Throwable ) { + return false; + } + } + return $this->runtimes[ $prefix ]->supports_transactional_tables( $tables ); + } + public function close(): void { $this->advisory_locks->close(); $this->session->reset(); diff --git a/inc/native/class-wp-markdown-native-table-insert-parser.php b/inc/native/class-wp-markdown-native-table-insert-parser.php index 2451c3c..ba30c58 100644 --- a/inc/native/class-wp-markdown-native-table-insert-parser.php +++ b/inc/native/class-wp-markdown-native-table-insert-parser.php @@ -75,7 +75,7 @@ public function parse_rows( WP_Markdown_Query_Request $request ): array|WP_Markd if ( $replace || $ignore_duplicate || null !== $unless_exists ) { throw new WP_Markdown_Native_SQL_Parse_Error( 'unsupported_grammar', $this->current()->sql_offset(), 'mdi-native cannot combine INSERT IGNORE or INSERT SELECT FROM DUAL with ON DUPLICATE KEY UPDATE.' ); } - $upsert_assignments = $this->upsert_assignments(); + $upsert_assignments = $this->upsert_assignments( $table ); } $this->type( WP_Markdown_Native_SQL_Token::END ); $inserts = array(); @@ -351,8 +351,8 @@ private function identifier_list(): array { return array_values( $columns ); } - /** @return array */ - private function upsert_assignments(): array { + /** @return array */ + private function upsert_assignments( string $table ): array { $this->word( 'ON' ); $this->word( 'DUPLICATE' ); $this->word( 'KEY' ); @@ -369,12 +369,19 @@ private function upsert_assignments(): array { $source = $this->identifier(); $this->type( WP_Markdown_Native_SQL_Token::RIGHT_PAREN ); $kind = 'inserted'; - } elseif ( in_array( $this->current()->type(), array( WP_Markdown_Native_SQL_Token::WORD, WP_Markdown_Native_SQL_Token::QUOTED_IDENTIFIER ), true ) ) { - $source = $this->identifier(); - $kind = 'column'; } else { - $value = $this->literal(); - $kind = 'literal'; + $parser = new WP_Markdown_Native_Select_AST_Parser( $this->tokens, $this->position ); + $expression = $parser->scalar_value(); + $this->position = $parser->position(); + foreach ( $expression->columns() as $column ) { + if ( null !== $column->qualifier() && 0 !== strcasecmp( $table, $column->qualifier() ) ) { + throw new WP_Markdown_Native_SQL_Parse_Error( 'unsupported_mutation_column', $column->sql_offset(), 'A duplicate-key expression must reference its target table.' ); + } + } + $value = 'literal' === $expression->kind() + ? $expression->literal() + : ( new WP_Markdown_Native_Query_Parser() )->lower_scalar_expression( $expression, null, $table ); + $kind = $value instanceof WP_Markdown_Native_Query_Scalar_Expression ? 'expression' : 'literal'; } $assignments[] = array( 'target' => $target, 'kind' => $kind, 'source' => $source, 'value' => $value ); if ( WP_Markdown_Native_SQL_Token::COMMA !== $this->current()->type() ) { diff --git a/inc/native/class-wp-markdown-native-table-mutations.php b/inc/native/class-wp-markdown-native-table-mutations.php index ccc48a2..7904cda 100644 --- a/inc/native/class-wp-markdown-native-table-mutations.php +++ b/inc/native/class-wp-markdown-native-table-mutations.php @@ -117,6 +117,16 @@ private function execute_insert( WP_Markdown_Query_Request $request, WP_Markdown if ( ! $schema->has_column( $assignment['target'] ) || ( null !== $assignment['source'] && ! $schema->has_column( $assignment['source'] ) ) ) { return $this->failure( 'unsupported_column', 'The duplicate-key assignment references an undeclared column.' ); } + if ( $assignment['value'] instanceof WP_Markdown_Native_Query_Scalar_Expression ) { + if ( ! WP_Markdown_Native_Scalar_Evaluator::supports( $assignment['value'] ) ) { + return $this->failure( 'unsupported_mutation_expression', 'The duplicate-key assignment uses an unsupported expression.' ); + } + foreach ( $assignment['value']->columns() as $column ) { + if ( ! $schema->has_column( $column ) ) { + return $this->failure( 'unsupported_column', 'The duplicate-key assignment references an undeclared column.' ); + } + } + } } if ( ! $this->supports_unique_indexes( $definition ) ) { return $this->failure( 'unsupported_unique_collation', 'mdi-native cannot enforce a persisted string or prefix unique key without its exact collation.' ); @@ -213,13 +223,15 @@ private function execute_insert( WP_Markdown_Query_Request $request, WP_Markdown } $duplicate = $duplicates[0]; $updated = $rows[ $duplicate ]; + $scalar_runtime = new WP_Markdown_Native_Query_Runtime( $this->registry, new WP_Markdown_Native_Query_Parser() ); foreach ( $upsert_assignments as $assignment ) { // Existing-column references see earlier assignments; VALUES sees the proposed insert. - $updated[ $assignment['target'] ] = match ( $assignment['kind'] ) { + $value = match ( $assignment['kind'] ) { 'inserted' => $row[ $assignment['source'] ], - 'column' => $updated[ $assignment['source'] ], + 'expression' => $scalar_runtime->evaluate_scalar( $assignment['value'], $updated, $schema ), default => $assignment['value'], }; + $updated[ $assignment['target'] ] = null === $value ? null : (string) $value; } if ( true !== $schema->validate_row( $updated ) ) { return $this->failure( 'invalid_insert_row', 'The INSERT row is outside the persisted table schema.' ); diff --git a/inc/native/class-wp-markdown-native-table-providers.php b/inc/native/class-wp-markdown-native-table-providers.php index 6aa6aba..adf3dbf 100644 --- a/inc/native/class-wp-markdown-native-table-providers.php +++ b/inc/native/class-wp-markdown-native-table-providers.php @@ -10,7 +10,7 @@ require_once __DIR__ . '/class-wp-markdown-native-post-catalogue.php'; require_once __DIR__ . '/class-wp-markdown-native-option-catalogue.php'; -abstract class WP_Markdown_Native_File_Provider implements WP_Markdown_Native_Table_Provider { +abstract class WP_Markdown_Native_File_Provider implements WP_Markdown_Native_Canonical_Table_Provider { protected string $state_root; @@ -25,6 +25,10 @@ public function __construct( $this->state_root = rtrim( $root, DIRECTORY_SEPARATOR ); } + public function canonical_root(): string { + return $this->state_root; + } + protected function failure( string $code, string $reason, string $message ): WP_Markdown_Query_Result { return WP_Markdown_Query_Result::failure( array( diff --git a/inc/native/class-wp-markdown-native-table-statements.php b/inc/native/class-wp-markdown-native-table-statements.php index fdc7e43..af71087 100644 --- a/inc/native/class-wp-markdown-native-table-statements.php +++ b/inc/native/class-wp-markdown-native-table-statements.php @@ -9,7 +9,7 @@ final class WP_Markdown_Native_Table_Insert { /** * @param array $values * @param array|null $unless_exists - * @param array|null $upsert_assignments + * @param array|null $upsert_assignments */ public function __construct( private readonly string $table, @@ -38,7 +38,7 @@ public function ignores_duplicate(): bool { return $this->ignore_duplicate; } - /** @return array|null */ + /** @return array|null */ public function upsert_assignments(): ?array { return $this->upsert_assignments; } diff --git a/inc/native/class-wp-markdown-native-transactions.php b/inc/native/class-wp-markdown-native-transactions.php index d8e88ec..c0bc8bc 100644 --- a/inc/native/class-wp-markdown-native-transactions.php +++ b/inc/native/class-wp-markdown-native-transactions.php @@ -32,6 +32,7 @@ final class WP_Markdown_Native_Transaction_Journal { private bool $autocommit = true; private bool $in_transaction = false; private bool $recovery_required = false; + private bool $waited_for_write_lock = false; /** @var list */ private array $entries = array(); @@ -65,6 +66,15 @@ public function admit_roots( array $admitted_roots ): void { $this->admitted_roots = array_values( array_unique( $this->admitted_roots ) ); } + /** Whether a provider's exact canonical root was admitted by the runtime factory. */ + public function covers_root( string $root ): bool { + $resolved = realpath( $root ); + return false !== $resolved + && is_dir( $resolved ) + && ! is_link( $root ) + && in_array( rtrim( $resolved, DIRECTORY_SEPARATOR ), $this->admitted_roots, true ); + } + public function is_active(): bool { return $this->active; } @@ -130,6 +140,11 @@ public function begin_write(): true|string { return $recovered; } + /** Whether the most recent root-lock acquisition had to wait for another process. */ + public function waited_for_write_lock(): bool { + return $this->waited_for_write_lock; + } + /** Acquire the stable root lock without attempting recovery recursively. */ private function acquire_write_lock(): true|string { $directory = $this->journal_directory(); @@ -145,11 +160,13 @@ private function acquire_write_lock(): true|string { return 'The canonical transaction write lock could not be opened.'; } $deadline = hrtime( true ) + ( self::WRITE_LOCK_WAIT_US * 1000 ); + $this->waited_for_write_lock = false; do { if ( flock( $handle, LOCK_EX | LOCK_NB ) ) { $this->write_lock = $handle; return true; } + $this->waited_for_write_lock = true; usleep( 10000 ); } while ( hrtime( true ) < $deadline ); fclose( $handle ); @@ -294,12 +311,27 @@ public function begin(): true|string { return $commit; } } + $locked = $this->begin_write(); + if ( true !== $locked ) { + return $locked; + } $this->active = true; $this->in_transaction = true; $this->entries = array(); $this->savepoints = array(); $this->restore_observers = array(); - return $this->persist(); + $persisted = $this->persist(); + if ( true === $persisted ) { + // A foreign abandoned journal can appear after this transaction begins. + // Its next canonical admission must scan before reading or mutating. + $this->recovery_required = true; + return true; + } + $this->active = false; + $this->in_transaction = false; + $this->release(); + $this->finish_write(); + return $persisted; } /** Capture the current state of a canonical path before it is mutated. */ @@ -461,13 +493,21 @@ public function release_savepoint( string $name ): true|string { return true; } - /** Disabling autocommit defers the implicit transaction until a write. */ + /** Disabling autocommit starts the lock-protected implicit transaction. */ public function set_autocommit( bool $enabled ): true|string { - $this->autocommit = $enabled; if ( $enabled ) { + $this->autocommit = true; return $this->commit(); } - return true; + $this->autocommit = false; + if ( $this->active ) { + return true; + } + $begun = $this->begin(); + if ( true !== $begun ) { + $this->autocommit = true; + } + return $begun; } /** diff --git a/inc/native/class-wp-markdown-native-wpdb.php b/inc/native/class-wp-markdown-native-wpdb.php index 207c5af..753db92 100644 --- a/inc/native/class-wp-markdown-native-wpdb.php +++ b/inc/native/class-wp-markdown-native-wpdb.php @@ -81,6 +81,24 @@ public function check_connection( $allow_bail = true ) { return $this->ready || $this->db_connect( $allow_bail ); } + /** + * Report whether every requested exact table shares native's journaled + * canonical transaction boundary. Unsupported runtimes and unsafe roots fail + * closed; this does not claim InnoDB, MVCC, or a mysqli connection. + * + * @param string[] $tables + */ + public function supports_transactional_tables( array $tables ): bool { + if ( ! $this->native_runtime instanceof WP_Markdown_Native_Transactional_Table_Support ) { + return false; + } + try { + return true === $this->native_runtime->supports_transactional_tables( $tables ); + } catch ( Throwable ) { + return false; + } + } + /** Close the logical native connection while leaving its configured root intact. */ public function close() { if ( ! $this->ready ) { diff --git a/tests/probe-native-identity-reservation-transaction.php b/tests/probe-native-identity-reservation-transaction.php new file mode 100644 index 0000000..e796fc6 --- /dev/null +++ b/tests/probe-native-identity-reservation-transaction.php @@ -0,0 +1,108 @@ +execute( new WP_Markdown_Query_Request( $sql, 'wp_' ) ); +} + +function mdi_reservation_remove_tree( string $root ): void { + $entries = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::CHILD_FIRST ); + foreach ( $entries as $entry ) { + $entry->isDir() && ! $entry->isLink() ? rmdir( $entry->getPathname() ) : unlink( $entry->getPathname() ); + } + rmdir( $root ); +} + +function mdi_reservation_value( WP_Markdown_Native_Query_Runtime $runtime, string $sql, string $column ): ?string { + $result = mdi_reservation_query( $runtime, $sql ); + $row = $result->wpdb_state()['last_result'][0] ?? null; + return is_object( $row ) ? ( $row->{$column} ?? null ) : null; +} + +if ( isset( $argv[1], $argv[2] ) && in_array( $argv[1], array( 'crash-owner', 'read-owner', 'writer', 'observer' ), true ) ) { + $mode = $argv[1]; + $root = $argv[2]; + $runtime = mdi_reservation_runtime( $root ); + if ( 'crash-owner' === $mode ) { + mdi_reservation_query( $runtime, 'START TRANSACTION' ); + $reservation = mdi_reservation_query( $runtime, "UPDATE wp_identity_reservations SET owner = 'first' WHERE resource_key = 'identity' AND owner = ''" ); + $post = mdi_reservation_query( $runtime, "UPDATE wp_posts SET post_title = 'first' WHERE ID = 1" ); + file_put_contents( $root . '/first-written', '1' ); + for ( $attempt = 0; $attempt < 1000 && ! is_file( $root . '/crash-first' ); ++$attempt ) { usleep( 10000 ); } + exit( 1 === $reservation->return_value() && 1 === $post->return_value() && is_file( $root . '/crash-first' ) ? 0 : 1 ); + } + if ( 'read-owner' === $mode ) { + mdi_reservation_query( $runtime, 'START TRANSACTION' ); + $read = mdi_reservation_query( $runtime, "SELECT owner FROM wp_identity_reservations WHERE resource_key = 'identity' FOR UPDATE" ); + file_put_contents( $root . '/read-ready', '1' ); + for ( $attempt = 0; $attempt < 1000 && ! is_file( $root . '/finish-read' ); ++$attempt ) { usleep( 10000 ); } + exit( 1 === $read->return_value() && is_file( $root . '/finish-read' ) && 0 === mdi_reservation_query( $runtime, 'ROLLBACK' )->return_value() ? 0 : 1 ); + } + if ( 'observer' === $mode ) { + $owner = mdi_reservation_value( $runtime, "SELECT owner FROM wp_identity_reservations WHERE resource_key = 'identity'", 'owner' ); + $title = mdi_reservation_value( $runtime, 'SELECT post_title FROM wp_posts WHERE ID = 1', 'post_title' ); + file_put_contents( $root . '/observer.json', json_encode( array( 'owner' => $owner, 'title' => $title ), JSON_THROW_ON_ERROR ) ); + exit( 0 ); + } + $reservation = mdi_reservation_query( $runtime, "UPDATE wp_identity_reservations SET owner = 'second' WHERE resource_key = 'identity' AND owner = ''" ); + $post = mdi_reservation_query( $runtime, "UPDATE wp_posts SET post_title = 'second' WHERE ID = 1" ); + exit( 1 === $reservation->return_value() && 1 === $post->return_value() ? 0 : 1 ); +} + +$root = sys_get_temp_dir() . '/mdi-native-reservation-transaction-' . bin2hex( random_bytes( 6 ) ); +mkdir( $root . '/_options', 0755, true ); +$setup = mdi_reservation_runtime( $root ); +mdi_reservation_query( $setup, 'CREATE TABLE wp_identity_reservations (resource_key VARCHAR(100) NOT NULL, owner VARCHAR(100) NOT NULL, PRIMARY KEY (resource_key))' ); +mdi_reservation_query( $setup, "INSERT INTO wp_identity_reservations (resource_key, owner) VALUES ('identity', '')" ); +mdi_reservation_query( $setup, "INSERT INTO wp_posts (ID, post_author, post_date, post_date_gmt, post_content, post_title, post_excerpt, post_status, comment_status, ping_status, post_password, post_name, to_ping, pinged, post_modified, post_modified_gmt, post_content_filtered, post_parent, guid, menu_order, post_type, post_mime_type, comment_count) VALUES (1, 1, '2026-09-10 00:00:00', '2026-09-10 00:00:00', '', 'base', '', 'publish', 'open', 'open', '', 'base', '', '', '2026-09-10 00:00:00', '2026-09-10 00:00:00', '', 0, '', 0, 'post', '', 0)" ); + +$crash_owner = proc_open( array( PHP_BINARY, __FILE__, 'crash-owner', $root ), array( 0 => array( 'pipe', 'r' ), 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $crash_pipes ); +for ( $attempt = 0; $attempt < 1000 && ! is_file( $root . '/first-written' ); ++$attempt ) { usleep( 10000 ); } +$observer = is_file( $root . '/first-written' ) ? proc_open( array( PHP_BINARY, __FILE__, 'observer', $root ), array( 0 => array( 'pipe', 'r' ), 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $observer_pipes ) : false; +$writer = is_file( $root . '/first-written' ) ? proc_open( array( PHP_BINARY, __FILE__, 'writer', $root ), array( 0 => array( 'pipe', 'r' ), 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $writer_pipes ) : false; +usleep( 200000 ); +$writer_waiting = is_resource( $writer ) && proc_get_status( $writer )['running']; +file_put_contents( $root . '/crash-first', '1' ); +$crash_status = is_resource( $crash_owner ) ? proc_close( $crash_owner ) : 1; +$observer_status = is_resource( $observer ) ? proc_close( $observer ) : 1; +$writer_status = is_resource( $writer ) ? proc_close( $writer ) : 1; +$observed = json_decode( (string) @file_get_contents( $root . '/observer.json' ), true ); +$final = mdi_reservation_runtime( $root ); +$final_owner = mdi_reservation_value( $final, "SELECT owner FROM wp_identity_reservations WHERE resource_key = 'identity'", 'owner' ); +$final_title = mdi_reservation_value( $final, 'SELECT post_title FROM wp_posts WHERE ID = 1', 'post_title' ); + +mdi_reservation_query( $final, "UPDATE wp_identity_reservations SET owner = '' WHERE resource_key = 'identity'" ); +$read_owner = proc_open( array( PHP_BINARY, __FILE__, 'read-owner', $root ), array( 0 => array( 'pipe', 'r' ), 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $read_pipes ); +for ( $attempt = 0; $attempt < 1000 && ! is_file( $root . '/read-ready' ); ++$attempt ) { usleep( 10000 ); } +$read_writer = is_file( $root . '/read-ready' ) ? proc_open( array( PHP_BINARY, __FILE__, 'writer', $root ), array( 0 => array( 'pipe', 'r' ), 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $read_writer_pipes ) : false; +usleep( 200000 ); +$read_writer_waiting = is_resource( $read_writer ) && proc_get_status( $read_writer )['running']; +file_put_contents( $root . '/finish-read', '1' ); +$read_owner_status = is_resource( $read_owner ) ? proc_close( $read_owner ) : 1; +$read_writer_status = is_resource( $read_writer ) ? proc_close( $read_writer ) : 1; + +$checks = array( + 'crashed transaction writer holds the root lock before its first canonical mutation' => $writer_waiting, + 'waiting writer recovers JSON reservation and Markdown shell before admission' => 0 === $crash_status && 0 === $writer_status && 'second' === $final_owner && 'second' === $final_title, + 'independent observer sees one committed reservation and shell state after rollback' => 0 === $observer_status && ( array( '', 'base' ) === array( $observed['owner'] ?? null, $observed['title'] ?? null ) || array( 'second', 'second' ) === array( $observed['owner'] ?? null, $observed['title'] ?? null ) ), + 'START TRANSACTION before SELECT FOR UPDATE retains the root lock until rollback admits the writer' => 0 === $read_owner_status && 0 === $read_writer_status && $read_writer_waiting, +); +fwrite( STDERR, json_encode( compact( 'crash_status', 'observer_status', 'writer_status', 'observed', 'final_owner', 'final_title', 'read_owner_status', 'read_writer_status', 'writer_waiting', 'read_writer_waiting' ), JSON_THROW_ON_ERROR ) . "\n" ); +mdi_reservation_remove_tree( $root ); +$passed = ! in_array( false, $checks, true ); +foreach ( $checks as $description => $result ) { fwrite( $passed ? STDOUT : STDERR, sprintf( "%s: %s\n", $result ? 'PASS' : 'FAIL', $description ) ); } +exit( $passed ? 0 : 1 ); diff --git a/tests/smoke-native-advisory-lock-processes.php b/tests/smoke-native-advisory-lock-processes.php new file mode 100644 index 0000000..e325d49 --- /dev/null +++ b/tests/smoke-native-advisory-lock-processes.php @@ -0,0 +1,49 @@ +execute( new WP_Markdown_Query_Request( $sql ) )->wpdb_state()['last_result']; + return isset( $rows[0] ) ? current( get_object_vars( $rows[0] ) ) : null; +} + +if ( isset( $argv[1], $argv[2] ) && in_array( $argv[1], array( 'owner', 'waiter', 'timeout' ), true ) ) { + $runtime = WP_Markdown_Native_Runtime_Factory::runtime( $argv[2] ); + if ( 'owner' === $argv[1] ) { + $acquired = mdi_advisory_process_value( $runtime, "SELECT GET_LOCK('reservation-identity', 0)" ); + file_put_contents( $argv[2] . '/owner-ready', '1' ); + for ( $attempt = 0; $attempt < 1000 && ! is_file( $argv[2] . '/release-owner' ); ++$attempt ) { usleep( 10000 ); } + exit( '1' === $acquired && is_file( $argv[2] . '/release-owner' ) && '1' === mdi_advisory_process_value( $runtime, "SELECT RELEASE_LOCK('reservation-identity')" ) ? 0 : 1 ); + } + $value = mdi_advisory_process_value( $runtime, "SELECT GET_LOCK('reservation-identity', " . ( 'waiter' === $argv[1] ? '2' : '0.1' ) . ')' ); + exit( ( 'waiter' === $argv[1] ? '1' : '0' ) === $value ? 0 : 1 ); +} + +$root = sys_get_temp_dir() . '/mdi-native-advisory-processes-' . bin2hex( random_bytes( 6 ) ); +mkdir( $root . '/_options', 0755, true ); +$owner = proc_open( array( PHP_BINARY, __FILE__, 'owner', $root ), array( 0 => array( 'pipe', 'r' ), 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $owner_pipes ); +for ( $attempt = 0; $attempt < 1000 && ! is_file( $root . '/owner-ready' ); ++$attempt ) { usleep( 10000 ); } +$timeout = is_file( $root . '/owner-ready' ) ? proc_open( array( PHP_BINARY, __FILE__, 'timeout', $root ), array( 0 => array( 'pipe', 'r' ), 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $timeout_pipes ) : false; +$timeout_status = is_resource( $timeout ) ? proc_close( $timeout ) : 1; +$waiter = is_file( $root . '/owner-ready' ) ? proc_open( array( PHP_BINARY, __FILE__, 'waiter', $root ), array( 0 => array( 'pipe', 'r' ), 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $waiter_pipes ) : false; +usleep( 200000 ); +$waiter_waiting = is_resource( $waiter ) && proc_get_status( $waiter )['running']; +file_put_contents( $root . '/release-owner', '1' ); +$owner_status = is_resource( $owner ) ? proc_close( $owner ) : 1; +$waiter_status = is_resource( $waiter ) ? proc_close( $waiter ) : 1; + +$entries = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::CHILD_FIRST ); +foreach ( $entries as $entry ) { $entry->isDir() && ! $entry->isLink() ? rmdir( $entry->getPathname() ) : unlink( $entry->getPathname() ); } +rmdir( $root ); +$checks = array( + 'contending process receives zero after its bounded timeout' => 0 === $timeout_status, + 'waiter remains excluded until the owning process releases' => $waiter_waiting, + 'waiter acquires after cross-process release' => 0 === $owner_status && 0 === $waiter_status, +); +$passed = ! in_array( false, $checks, true ); +foreach ( $checks as $description => $result ) { fwrite( $passed ? STDOUT : STDERR, sprintf( "%s: %s\n", $result ? 'PASS' : 'FAIL', $description ) ); } +exit( $passed ? 0 : 1 ); diff --git a/tests/smoke-native-request-snapshot.php b/tests/smoke-native-request-snapshot.php index d14378f..fd2c9f7 100644 --- a/tests/smoke-native-request-snapshot.php +++ b/tests/smoke-native-request-snapshot.php @@ -56,7 +56,7 @@ && '1' === (string) ( $after_append_count->wpdb_state()['last_result'][0]->{'COUNT(*)'} ?? '' ), 'a transaction reads its own generic-table write' => 'temporary' === (string) ( $transaction_rows[0]->label ?? '' ), 'rollback invalidates and reloads the restored snapshot' => 'one' === (string) ( $rollback_rows[0]->label ?? '' ), - 'external changes do not alter a loaded request snapshot' => 'one' === (string) ( $stable_rows[0]->label ?? '' ), + 'an autocommit request refreshes its snapshot at the next canonical admission' => 'external' === (string) ( $stable_rows[0]->label ?? '' ), 'a new request observes the externally published snapshot' => 'external' === (string) ( $fresh_rows[0]->label ?? '' ), ); diff --git a/tests/smoke-native-table-upsert.php b/tests/smoke-native-table-upsert.php index 6ff776a..d3727ae 100644 --- a/tests/smoke-native-table-upsert.php +++ b/tests/smoke-native-table-upsert.php @@ -35,8 +35,12 @@ $ordered_row = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT object_id, title FROM wp_yoast_indexable WHERE id = 1' ) ); $cross_values = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (id, object_id, title) VALUES (1, 9, 'incoming') ON DUPLICATE KEY UPDATE title = VALUES(object_id)" ) ); $bad_target = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (id, object_id, title) VALUES (3, 10, 'new') ON DUPLICATE KEY UPDATE absent_column = NULL" ) ); -$new_literal = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (id, object_id, title) VALUES (3, 10, 'new') ON DUPLICATE KEY UPDATE title = NULL" ) ); +$new_literal = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (id, object_id, title) VALUES (3, 11, 'new') ON DUPLICATE KEY UPDATE title = NULL" ) ); $new_row = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT title FROM wp_yoast_indexable WHERE id = 3' ) ); +$increment = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (id, object_id, title) VALUES (1, 9, 'ignored') ON DUPLICATE KEY UPDATE object_id = object_id + 1" ) ); +$incremented_row = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT object_id FROM wp_yoast_indexable WHERE id = 1' ) ); +$unknown_expression_column = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (id, object_id, title) VALUES (1, 10, 'ignored') ON DUPLICATE KEY UPDATE title = missing_column + 1" ) ); +$nondeterministic_expression = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (id, object_id, title) VALUES (1, 10, 'ignored') ON DUPLICATE KEY UPDATE object_id = RAND()" ) ); $runtime->execute( new WP_Markdown_Query_Request( 'CREATE TABLE wp_unique_labels (id int NOT NULL, label varchar(20) NOT NULL, PRIMARY KEY(id), UNIQUE KEY label(label))' ) ); $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_unique_labels (id,label) VALUES (1,'original')" ) ); $unsupported_unique = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_unique_labels (id,label) VALUES (1,'original') ON DUPLICATE KEY UPDATE label='caf\xC3\xA9'" ) ); @@ -50,6 +54,9 @@ 'VALUES may refer to a different inserted column' => $cross_values->succeeded() && 0 === $cross_values->return_value(), 'unknown duplicate target fails even on the insert branch' => ! $bad_target->succeeded() && 'unsupported_column' === $bad_target->diagnostic()['reason'], 'duplicate literals are not applied to a newly inserted row' => 1 === $new_literal->return_value() && 'new' === $new_row->corpus_result()['rows'][0]['title'], + 'duplicate assignments support bounded row-local arithmetic' => 2 === $increment->return_value() && '10' === $incremented_row->corpus_result()['rows'][0]['object_id'], + 'duplicate expressions reject unknown referenced columns' => ! $unknown_expression_column->succeeded() && 'unsupported_column' === $unknown_expression_column->diagnostic()['reason'], + 'duplicate expressions reject nondeterministic scalar functions' => ! $nondeterministic_expression->succeeded() && 'unsupported_mutation_expression' === $nondeterministic_expression->diagnostic()['reason'], 'the first insert persists' => 1 === $insert->return_value() && 1 === $insert->wpdb_state()['insert_id'], 'ON DUPLICATE KEY UPDATE rewrites the conflicting row' => 2 === $upsert->return_value() && 1 === $upsert->wpdb_state()['insert_id'] diff --git a/tests/smoke-native-transaction-read-isolation.php b/tests/smoke-native-transaction-read-isolation.php new file mode 100644 index 0000000..16ab800 --- /dev/null +++ b/tests/smoke-native-transaction-read-isolation.php @@ -0,0 +1,84 @@ +execute( new WP_Markdown_Query_Request( $sql, 'wp_' ) ); +} + +function mdi_read_isolation_value( WP_Markdown_Native_Query_Runtime $runtime ): ?string { + $rows = mdi_read_isolation_query( $runtime, "SELECT label FROM wp_reservations WHERE resource_key = 'identity'" )->wpdb_state()['last_result']; + return $rows[0]->label ?? null; +} + +if ( isset( $argv[1], $argv[2] ) && in_array( $argv[1], array( 'writer', 'reader' ), true ) ) { + $root = $argv[2]; + $runtime = mdi_read_isolation_runtime( $root ); + if ( 'writer' === $argv[1] ) { + $begin = mdi_read_isolation_query( $runtime, 'SET autocommit = 0' ); + $write = mdi_read_isolation_query( $runtime, "UPDATE wp_reservations SET label = 'committed' WHERE resource_key = 'identity'" ); + file_put_contents( $root . '/writer-ready', '1' ); + for ( $attempt = 0; $attempt < 1000 && ! is_file( $root . '/commit-writer' ); ++$attempt ) { usleep( 10000 ); } + $commit = mdi_read_isolation_query( $runtime, 'COMMIT' ); + exit( 0 === $begin->return_value() && 1 === $write->return_value() && is_file( $root . '/commit-writer' ) && 0 === $commit->return_value() ? 0 : 1 ); + } + $before = mdi_read_isolation_value( $runtime ); + file_put_contents( $root . '/reader-loaded', '1' ); + for ( $attempt = 0; $attempt < 1000 && ! is_file( $root . '/read-again' ); ++$attempt ) { usleep( 10000 ); } + $after = mdi_read_isolation_value( $runtime ); + file_put_contents( $root . '/reader.json', json_encode( array( 'before' => $before, 'after' => $after ), JSON_THROW_ON_ERROR ) ); + exit( 'base' === $before && 'committed' === $after ? 0 : 1 ); +} + +$root = sys_get_temp_dir() . '/mdi-native-read-isolation-' . bin2hex( random_bytes( 6 ) ); +mkdir( $root . '/_options', 0755, true ); +$setup = mdi_read_isolation_runtime( $root ); +mdi_read_isolation_query( $setup, 'CREATE TABLE wp_reservations (resource_key VARCHAR(100) NOT NULL, label VARCHAR(100) NOT NULL, PRIMARY KEY (resource_key))' ); +mdi_read_isolation_query( $setup, "INSERT INTO wp_reservations (resource_key, label) VALUES ('identity', 'base')" ); +$reader = proc_open( array( PHP_BINARY, __FILE__, 'reader', $root ), array( 0 => array( 'pipe', 'r' ), 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $reader_pipes ); +for ( $attempt = 0; $attempt < 1000 && ! is_file( $root . '/reader-loaded' ); ++$attempt ) { usleep( 10000 ); } +$writer = is_file( $root . '/reader-loaded' ) ? proc_open( array( PHP_BINARY, __FILE__, 'writer', $root ), array( 0 => array( 'pipe', 'r' ), 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $writer_pipes ) : false; +for ( $attempt = 0; $attempt < 1000 && ! is_file( $root . '/writer-ready' ); ++$attempt ) { usleep( 10000 ); } +file_put_contents( $root . '/read-again', '1' ); +usleep( 200000 ); +$reader_waiting = is_resource( $reader ) && proc_get_status( $reader )['running']; +file_put_contents( $root . '/commit-writer', '1' ); +$writer_status = is_resource( $writer ) ? proc_close( $writer ) : 1; +$reader_status = is_resource( $reader ) ? proc_close( $reader ) : 1; +$values = json_decode( (string) @file_get_contents( $root . '/reader.json' ), true ); + +mdi_read_isolation_query( $setup, "UPDATE wp_reservations SET label = 'base' WHERE resource_key = 'identity'" ); +@unlink( $root . '/reader-loaded' ); +@unlink( $root . '/writer-ready' ); +@unlink( $root . '/read-again' ); +@unlink( $root . '/commit-writer' ); +@unlink( $root . '/reader.json' ); +$idle_reader = proc_open( array( PHP_BINARY, __FILE__, 'reader', $root ), array( 0 => array( 'pipe', 'r' ), 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $idle_reader_pipes ); +for ( $attempt = 0; $attempt < 1000 && ! is_file( $root . '/reader-loaded' ); ++$attempt ) { usleep( 10000 ); } +$idle_writer = is_file( $root . '/reader-loaded' ) ? proc_open( array( PHP_BINARY, __FILE__, 'writer', $root ), array( 0 => array( 'pipe', 'r' ), 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $idle_writer_pipes ) : false; +for ( $attempt = 0; $attempt < 1000 && ! is_file( $root . '/writer-ready' ); ++$attempt ) { usleep( 10000 ); } +file_put_contents( $root . '/commit-writer', '1' ); +$idle_writer_status = is_resource( $idle_writer ) ? proc_close( $idle_writer ) : 1; +file_put_contents( $root . '/read-again', '1' ); +$idle_reader_status = is_resource( $idle_reader ) ? proc_close( $idle_reader ) : 1; +$idle_values = json_decode( (string) @file_get_contents( $root . '/reader.json' ), true ); + +$entries = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::CHILD_FIRST ); +foreach ( $entries as $entry ) { $entry->isDir() && ! $entry->isLink() ? rmdir( $entry->getPathname() ) : unlink( $entry->getPathname() ); } +rmdir( $root ); +$checks = array( + 'autocommit-off writer retains admission through commit' => $reader_waiting, + 'waiting reader reloads its generic snapshot after commit' => 0 === $writer_status && 0 === $reader_status && array( 'before' => 'base', 'after' => 'committed' ) === $values, + 'idle reader reloads its generic snapshot at its next canonical admission' => 0 === $idle_writer_status && 0 === $idle_reader_status && array( 'before' => 'base', 'after' => 'committed' ) === $idle_values, +); +$passed = ! in_array( false, $checks, true ); +foreach ( $checks as $description => $result ) { fwrite( $passed ? STDOUT : STDERR, sprintf( "%s: %s\n", $result ? 'PASS' : 'FAIL', $description ) ); } +exit( $passed ? 0 : 1 ); diff --git a/tests/smoke-native-wpdb-lifecycle.php b/tests/smoke-native-wpdb-lifecycle.php index 19272df..6e68ab4 100644 --- a/tests/smoke-native-wpdb-lifecycle.php +++ b/tests/smoke-native-wpdb-lifecycle.php @@ -61,10 +61,41 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query } } +final class MDI_Untrusted_Transactional_Provider implements WP_Markdown_Native_Table_Provider { + public function read( WP_Markdown_Native_Table_Access $access ): iterable|WP_Markdown_Query_Result { + unset( $access ); + return array(); + } +} + $root = sys_get_temp_dir() . '/mdi-native-wpdb-lifecycle-' . bin2hex( random_bytes( 6 ) ); mkdir( $root . '/_options', 0777, true ); mkdir( $root . '/_tables', 0777, true ); $database = new WP_Markdown_Native_WPDB( WP_Markdown_Native_Runtime_Factory::runtime( $root ) ); +$database->query( 'CREATE TABLE wp_reservations (identity_hash char(64) NOT NULL, PRIMARY KEY (identity_hash))' ); +$transactional_tables = $database->supports_transactional_tables( array( 'wp_posts', 'wp_reservations' ) ); +$unknown_table = $database->supports_transactional_tables( array( 'wp_posts', 'wp_missing' ) ); +$empty_table_set = $database->supports_transactional_tables( array() ); +$runtime = Closure::bind( fn(): WP_Markdown_Native_Query_Runtime => $this->native_runtime, $database, WP_Markdown_Native_WPDB::class )(); +$registry = Closure::bind( fn(): WP_Markdown_Native_Table_Registry => $this->registry, $runtime, WP_Markdown_Native_Query_Runtime::class )(); +$journal = Closure::bind( fn(): WP_Markdown_Native_Transaction_Journal => $this->transactions, $runtime, WP_Markdown_Native_Query_Runtime::class )(); +$reservation = $registry->table( 'wp_reservations' ); +$posts = $registry->table( 'wp_posts' ); +$reservation_definition = $registry->definition( 'wp_reservations' ); +$posts_definition = $registry->definition( 'wp_posts' ); +$registry->reregister( 'wp_reservations', $reservation['schema'], new MDI_Untrusted_Transactional_Provider(), $reservation_definition ); +$untrusted_provider = $database->supports_transactional_tables( array( 'wp_posts', 'wp_reservations' ) ); +$registry->reregister( 'wp_reservations', $reservation['schema'], $reservation['provider'], $reservation_definition ); +$outside_root = sys_get_temp_dir() . '/mdi-native-outside-root-' . bin2hex( random_bytes( 6 ) ); +mkdir( $outside_root, 0777, true ); +$registry->reregister( 'wp_reservations', $reservation['schema'], new WP_Markdown_Native_JSON_Snapshot_Provider( $outside_root, $reservation['schema'], 'reservations.json' ), $reservation_definition ); +$outside_root_provider = $database->supports_transactional_tables( array( 'wp_posts', 'wp_reservations' ) ); +$registry->reregister( 'wp_reservations', $reservation['schema'], $reservation['provider'], $reservation_definition ); +$read_only_runtime = new WP_Markdown_Native_Query_Runtime( $registry, transactions: $journal ); +$read_only_runtime_with_journal = $read_only_runtime->supports_transactional_tables( array( 'wp_posts', 'wp_reservations' ) ); +$registry->shadow( 'wp_posts', $posts['schema'], $posts['provider'], $posts_definition ); +$shadowed_table = $database->supports_transactional_tables( array( 'wp_posts', 'wp_reservations' ) ); +$registry->unshadow( 'wp_posts' ); $selection = $database->select( DB_NAME ); $closed = $database->close(); @@ -93,6 +124,12 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query $checks = array( 'database selection succeeds without mysqli, keeps wpdb return semantics, and preserves the canonical prefix' => null === $selection && 'wp_' === $database->prefix, 'native wpdb advertises the MySQL dialect without creating a mysqli connection' => true === $database->is_mysql, + 'native wpdb proves only configured canonical providers share its journaled transaction boundary' => $transactional_tables, + 'native wpdb rejects unknown and empty table sets' => ! $unknown_table && ! $empty_table_set, + 'native wpdb rejects arbitrary registered providers' => ! $untrusted_provider, + 'native wpdb rejects a canonical provider outside its journal roots' => ! $outside_root_provider, + 'native wpdb rejects a read-only runtime even when it has a journal' => ! $read_only_runtime_with_journal, + 'native wpdb rejects a temporary shadow of a canonical table' => ! $shadowed_table, 'logical close and reconnect report wpdb lifecycle state' => true === $closed && true === $reconnected && true === $database->ready, 'invalid selection exposes a normal database error state' => false === $invalid_selection && 1049 === $invalid_errno && 'Unknown database' === $invalid_error, 'connection checks restore the ready state without a reconnect loop' => true === $reconnected_after_error && true === $database->ready && 0 === $database->last_errno, @@ -114,4 +151,5 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query @rmdir( $root . '/_tables' ); @rmdir( $root . '/_options' ); @rmdir( $root ); +@rmdir( $outside_root ); exit( $failed ? 1 : 0 );