|null The queue as stored now, or null when other * requests kept changing it and this change * wasn't stored. */ public static function change( string $option, callable $change, bool $autoload = false ): ?array { global $wpdb; $current = array(); for ( $attempt = 0; $attempt < 10; ++$attempt ) { $stored = self::stored( $option ); $current = null === $stored ? array() : (array) maybe_unserialize( $stored ); $next = (array) $change( $current ); if ( $next === $current ) { return $current; } if ( null === $stored ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Adds it unless another request just did. $done = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, %s)", $option, maybe_serialize( $next ), $autoload ? 'on' : 'off' ) ); } elseif ( empty( $next ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Only while no other request has changed it. $done = $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", $option, $stored ) ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Only while no other request has changed it. $done = $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s", maybe_serialize( $next ), $option, $stored ) ); } wp_cache_delete( $option, 'options' ); wp_cache_delete( 'notoptions', 'options' ); if ( $autoload ) { wp_cache_delete( 'alloptions', 'options' ); } if ( $done ) { return $next; } } return null; } /** * Claims something only one request at a time may do, such as having * Stand create a page's source. A claim not renewed for as long as it was * taken for was left by a request that ended on the way. * * A shared claim is for what requests may do side by side while keeping * one thing off: changes made on one connection share the claim on * connecting, which connecting and disconnecting take alone. * * @param string $option Option name. * @param string $key What's claimed. * @param int $ttl Seconds the claim holds unless renewed. * @param bool $shared Whether other shared claims may hold it too. * @return string The claim's token, to renew and release it with; '' while * another request holds it. */ public static function claim( string $option, string $key, int $ttl, bool $shared = false ): string { $token = wp_generate_uuid4(); $claimed = false; $stored = self::change( $option, static function ( array $claims ) use ( $key, $ttl, $shared, $token, &$claimed ): array { $held = self::live( $claims[ $key ] ?? null, $ttl ); $claimed = null === $held || ( $shared && isset( $held['shared'] ) ); if ( $claimed ) { $claim = array( 'at' => time(), 'ttl' => $ttl, ); $claims[ $key ] = $shared ? array( 'shared' => array( $token => $claim ) + (array) ( $held['shared'] ?? array() ) ) : array( 'by' => $token ) + $claim; } return $claims; } ); return $claimed && null !== $stored ? $token : ''; } /** * Keeps a claim alive while what it's for goes on. * * @param string $option Option name. * @param string $key What was claimed. * @param string $token The claim's token. * @return bool Whether it's still this request's: false once another * request took it after it ran out. */ public static function renew( string $option, string $key, string $token ): bool { $held = false; $stored = self::change( $option, static function ( array $claims ) use ( $key, $token, &$held ): array { $held = true; if ( isset( $claims[ $key ]['shared'][ $token ] ) ) { $claims[ $key ]['shared'][ $token ]['at'] = time(); } elseif ( isset( $claims[ $key ]['by'] ) && $token === $claims[ $key ]['by'] ) { $claims[ $key ]['at'] = time(); } else { $held = false; } return $claims; } ); return $held && null !== $stored; } /** * Lets go of a claim, unless another request took it after it ran out. * * @param string $option Option name. * @param string $key What was claimed. * @param string $token The claim's token. */ public static function release( string $option, string $key, string $token ): void { self::change( $option, static function ( array $claims ) use ( $key, $token ): array { if ( isset( $claims[ $key ]['shared'][ $token ] ) ) { unset( $claims[ $key ]['shared'][ $token ] ); if ( empty( $claims[ $key ]['shared'] ) ) { unset( $claims[ $key ] ); } } elseif ( isset( $claims[ $key ]['by'] ) && $token === $claims[ $key ]['by'] ) { unset( $claims[ $key ] ); } return $claims; } ); } /** * A claim as it holds now: null once it ran out, and a shared one without * the requests whose part ran out. * * @param mixed $claim The stored claim, if any. * @param int $ttl Seconds it holds, for a claim stored without them. * @return array|null */ private static function live( $claim, int $ttl ): ?array { if ( null === $claim ) { return null; } if ( is_array( $claim ) && isset( $claim['shared'] ) ) { $shared = array_filter( (array) $claim['shared'], static function ( $part ) use ( $ttl ): bool { return ! self::ran_out( $part, $ttl ); } ); return empty( $shared ) ? null : array( 'shared' => $shared ); } return self::ran_out( $claim, $ttl ) ? null : (array) $claim; } /** * Whether a claim, or a request's part of a shared one, went unrenewed * for longer than it was taken for: the request ended on the way. * * @param mixed $claim A claim, or the time of one made before claims had tokens. * @param int $ttl Seconds it holds, for a claim stored without them. */ private static function ran_out( $claim, int $ttl ): bool { $at = (int) ( is_array( $claim ) ? ( $claim['at'] ?? 0 ) : $claim ); $ttl = is_array( $claim ) && isset( $claim['ttl'] ) ? (int) $claim['ttl'] : $ttl; return $at < time() - $ttl; } /** * The queue as stored now, which another request may have just changed. * * @param string $option Option name. * @return array */ public static function read( string $option ): array { $stored = self::stored( $option ); return null === $stored ? array() : (array) maybe_unserialize( $stored ); } /** * Stores an option's value, as it is, unless another request already * stored one. WordPress's add_option() would overwrite it. * * @param string $option Option name; the option isn't autoloaded. * @param string $value The value. * @return bool Whether this value was stored. */ public static function add( string $option, string $value ): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Adds it unless another request just did. $done = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'off')", $option, $value ) ); wp_cache_delete( $option, 'options' ); wp_cache_delete( 'notoptions', 'options' ); return (bool) $done; } /** * Replaces an option's stored value only while it's still the one read. * * @param string $option Option name. * @param string $expected The stored value, as stored() read it. * @param string $next The new value, stored as it is. * @return bool Whether it was replaced. */ public static function swap( string $option, string $expected, string $next ): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Only while no other request has changed it. $done = $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s", $next, $option, $expected ) ); wp_cache_delete( $option, 'options' ); wp_cache_delete( 'notoptions', 'options' ); return (bool) $done; } /** * The option's stored value, read past the options cache. * * @param string $option Option name. */ public static function stored( string $option ): ?string { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads past the options cache. $stored = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $option ) ); return null === $stored ? null : (string) $stored; } }