*/ private static array $held = array(); /** * Acquire a named advisory lock. * * Same-process re-entrancy is treated as contention by default. Pass the * `abilityguard_lock_reentrant` filter returning true to fall back to * MySQL-only behavior (re-entrant per connection). * * @param string $key Full lock key (including prefix). * @param int $timeout_seconds Seconds to wait. 0 = fail immediately. * * @return bool True when the lock was acquired; false on timeout / re-entrant guard. */ public static function acquire( string $key, int $timeout_seconds = 5 ): bool { $reentrant_ok = function_exists( 'apply_filters' ) ? (bool) apply_filters( 'abilityguard_lock_reentrant', false ) : false; if ( ! $reentrant_ok && isset( self::$held[ $key ] ) ) { return false; } global $wpdb; $timeout = max( 0, $timeout_seconds ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- advisory lock primitive, key is sha1-derived. $result = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $key, $timeout ) ); if ( null === $result ) { // NULL means an error occurred (e.g. misconfigured MySQL). // Log and proceed without the lock (degraded mode). if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( sprintf( 'AbilityGuard: GET_LOCK() returned NULL for key "%s". Proceeding without lock.', $key ) ); } return false; } $acquired = '1' === (string) $result; if ( $acquired ) { self::$held[ $key ] = true; } return $acquired; } /** * Release a named advisory lock. * * Only releases locks held by THIS connection. Releasing a lock not held * by this connection returns false (MySQL returns 0 or NULL). * * @param string $key Full lock key (including prefix). * * @return bool True if the lock was released by this connection. */ public static function release( string $key ): bool { unset( self::$held[ $key ] ); global $wpdb; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- advisory lock primitive, key is sha1-derived. $result = $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $key ) ); return '1' === (string) $result; } /** * Whether this process currently holds a given lock key. * * Lets nested ability calls detect that an ancestor invocation already * holds the lock for the same surface set so they can skip the acquire * (and the release) instead of deadlocking against themselves. * * @param string $key Full lock key (including prefix). */ public static function is_held( string $key ): bool { return isset( self::$held[ $key ] ); } /** * Reset the in-process held set. Test-only. * * @internal */ public static function reset_for_tests(): void { self::$held = array(); } /** * Derive a deterministic lock key from a resolved snapshot spec. * * Algorithm: * 1. Walk each surface in the spec and emit `surface:identifier` strings. * 2. For `post_meta`: emit `post_meta::` for every key. * 3. For `options`: emit `options:`. * 4. For `taxonomy`: emit `taxonomy::`. * 5. For `user_role`: emit `user_role:`. * 6. For `files`: emit `files:`. * 7. Sort the list, join with `|`, sha1 the result. * 8. Prefix with `abilityguard:`. * * Two invocations that share NO surface identifiers get different keys and * therefore do NOT block each other. * * @param array $spec Resolved snapshot spec. * * @return string Lock key, e.g. `abilityguard:da39a3ee5e6b4b0d3255...`. */ public static function key_for_spec( array $spec ): string { $parts = array(); foreach ( $spec as $surface => $surface_spec ) { switch ( $surface ) { case 'post_meta': // Format: post_id mapped to an array of meta keys. if ( is_array( $surface_spec ) ) { foreach ( $surface_spec as $post_id => $keys ) { if ( is_array( $keys ) ) { foreach ( $keys as $meta_key ) { $parts[] = 'post_meta:' . (string) $post_id . ':' . (string) $meta_key; } } } } break; case 'options': // Format: flat list of option names. if ( is_array( $surface_spec ) ) { foreach ( $surface_spec as $option ) { $parts[] = 'options:' . (string) $option; } } break; case 'taxonomy': // Format: taxonomy name mapped to an array of term ids. if ( is_array( $surface_spec ) ) { foreach ( $surface_spec as $taxonomy => $term_ids ) { if ( is_array( $term_ids ) ) { foreach ( $term_ids as $term_id ) { $parts[] = 'taxonomy:' . (string) $taxonomy . ':' . (string) $term_id; } } } } break; case 'user_role': // Format: flat list of user ids. if ( is_array( $surface_spec ) ) { foreach ( $surface_spec as $user_id ) { $parts[] = 'user_role:' . (string) $user_id; } } break; case 'files': // Format: indexed list of paths or associative path-keyed array. if ( is_array( $surface_spec ) ) { foreach ( $surface_spec as $key => $val ) { // Accept both indexed (path as value) and associative (path as key). $path = is_int( $key ) ? (string) $val : (string) $key; $parts[] = 'files:' . $path; } } break; default: // Unknown surface: add a generic entry so different unknown // surfaces still get distinct identifiers. $parts[] = (string) $surface . ':' . wp_json_encode( $surface_spec ); break; } } sort( $parts ); return self::PREFIX . sha1( implode( '|', $parts ) ); } }