nesting depth. * * @var array */ private static array $held = array(); /** * Run $work while holding the lock for $key. * * @template T * * @param string $key What is being guarded, e.g. `lead_convert:12`. * @param callable(): T $work The check-then-write. * @param int|null $timeout Seconds to wait for another request to finish. Null = default. * * @return T|WP_Error The work's result, or `plugixa_crm_busy` (409) when the lock never came free. */ public static function run( string $key, callable $work, ?int $timeout = null ): mixed { if ( ! self::acquire( $key, $timeout ) ) { return self::busy(); } try { return $work(); } finally { self::release( $key ); } } /** * Take the lock and keep it until this request ends. * * For a guard whose write happens later in the request than the check — the edit-conflict check runs * in the permission callback, the save in the controller. The server releases it when the connection * closes; the shutdown hook only makes that explicit for long-lived processes. * * @param string $key What is being guarded. * @param int|null $timeout Seconds to wait. */ public static function hold( string $key, ?int $timeout = null ): bool { if ( ! self::acquire( $key, $timeout ) ) { return false; } add_action( 'shutdown', static function () use ( $key ): void { self::release( $key ); } ); return true; } /** * @param string $key What is being guarded. * @param int|null $timeout Seconds to wait. */ public static function acquire( string $key, ?int $timeout = null ): bool { $name = self::name( $key ); if ( isset( self::$held[ $name ] ) ) { ++self::$held[ $name ]; return true; } global $wpdb; /** * How long a request waits for an overlapping one to finish before answering 409. * * @param int $seconds Default 10. * @param string $key The guarded key. */ $timeout = null === $timeout ? (int) apply_filters( 'plugixa_crm_lock_timeout', 10, $key ) : $timeout; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- a lock, not data; nothing to cache. $result = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $name, max( 0, $timeout ) ) ); if ( '0' === (string) $result && null !== $result ) { return false; } self::$held[ $name ] = 1; return true; } /** * @param string $key What was guarded. */ public static function release( string $key ): void { $name = self::name( $key ); if ( ! isset( self::$held[ $name ] ) ) { return; } if ( --self::$held[ $name ] > 0 ) { return; } unset( self::$held[ $name ] ); global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- a lock, not data; nothing to cache. $wpdb->query( $wpdb->prepare( 'DO RELEASE_LOCK(%s)', $name ) ); } /** * The server-side name for a key: per site, so two sites sharing a database never block each other. * * @param string $key What is being guarded. */ public static function name( string $key ): string { global $wpdb; return self::PREFIX . md5( $wpdb->prefix . $key ); } /** * The error a request gets when an overlapping one held the lock for the whole timeout. */ public static function busy(): WP_Error { return new WP_Error( 'plugixa_crm_busy', __( 'Someone else is changing this right now. Try again in a moment.', 'plugixa-crm' ), array( 'status' => 409 ) ); } }