Skip to content
90 changes: 90 additions & 0 deletions inc/native/class-wp-markdown-native-advisory-locks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php
/** Root-scoped, process-safe named locks for the native MySQL compatibility layer. */

if ( ! defined( 'ABSPATH' ) ) {
exit;
}

final class WP_Markdown_Native_Advisory_Locks {

private const DIRECTORY = '_locks';
/** The largest ordinary consumer lock wait accepted by the native runtime. */
public const MAX_WAIT_SECONDS = 10.0;

/** @var array<string,array{handle:resource,count:int,path:string}> */
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';
}
}
39 changes: 37 additions & 2 deletions inc/native/class-wp-markdown-native-option-mutations.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.' );
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 ) {
Expand Down
39 changes: 38 additions & 1 deletion inc/native/class-wp-markdown-native-query-executor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}
Expand All @@ -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(
Expand Down Expand Up @@ -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 );
Expand Down
44 changes: 39 additions & 5 deletions inc/native/class-wp-markdown-native-query-runtime.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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' ) ),
Expand Down Expand Up @@ -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 ) {
Expand Down Expand Up @@ -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 )
);
}

Expand Down Expand Up @@ -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 ) ) {
Expand Down Expand Up @@ -598,11 +610,14 @@ final class WP_Markdown_Native_Prefix_Query_Runtime implements WP_Markdown_Query

/** @var array<string,WP_Markdown_Native_Query_Runtime> */
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();
Expand All @@ -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. */
Expand Down Expand Up @@ -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. */
Expand All @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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(
Expand All @@ -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;
Expand Down
19 changes: 14 additions & 5 deletions inc/native/class-wp-markdown-native-transactions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand All @@ -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 ) {
Expand Down
Loading