HEX
Server: nginx
System: Linux pool64-304-45.dca.atomicsites.net 5.10.0-31-amd64 #1 SMP Debian 5.10.221-1 (2024-07-14) x86_64
User: (0)
PHP: 8.4.19
Disabled: pcntl_fork
Upload Files
File: //scripts/object-cache.memcached.php
<?php

/**
 * Plugin Name: Memcached
 * Description: Memcached backend for the WordPress object cache (wp-ok-cache).
 * Version: 1.0.0
 * Author: Automattic
 * License: GPL version 2 or later - http://www.gnu.org/licenses/old-licenses/gpl-2.0.html
 */

// Users with setups where multiple installs share a common wp-config.php or $table_prefix
// can use this to guarantee uniqueness for the keys generated by this object cache
if ( ! defined( 'WP_CACHE_KEY_SALT' ) ) {
	define( 'WP_CACHE_KEY_SALT', '' );
}

/**
 * Helps keep track of cache-related stats.
 *
 * TODO: Once public access to these methods/properties is deprecated in the main class,
 * we can cleanup the data structures internally here.
 */
class WP_Object_Cache_Stats {
	/** @var array<string,int> */
	public array $stats = [];

	/**
	* @psalm-var array<string, array<array{0: string, 1: string|string[], 2: int|null, 3: float|null, 4: string, 5: string, 6: string|null }>>
	*/
	public array $group_ops = [];

	public float $time_total = 0;
	public int $size_total   = 0;

	public float $slow_op_microseconds = 0.005; // 5 ms

	private string $key_salt;

	public function __construct( string $key_salt ) {
		$this->key_salt = $key_salt;

		$this->stats = [
			'get'          => 0,
			'get_local'    => 0,
			'get_multi'    => 0,
			'set'          => 0,
			'set_local'    => 0,
			'add'          => 0,
			'delete'       => 0,
			'delete_local' => 0,
			'slow-ops'     => 0,
		];
	}

	/*
	|--------------------------------------------------------------------------
	| Stat tracking.
	|--------------------------------------------------------------------------
	*/

	/**
	 * Keep stats for a memcached operation.
	 *
	 * @param string $op The operation taking place, such as "set" or "get".
	 * @param string|string[] $keys The memcached key/keys involved in the operation.
	 * @param string $group The group the keys are in.
	 * @param ?int $size The size of the data invovled in the operation.
	 * @param ?float $time The time the operation took.
	 * @param string $comment Extra notes about the operation.
	 *
	 * @return void
	 */
	public function group_ops_stats( $op, $keys, $group, $size = null, $time = null, $comment = '' ) {
		$this->increment_stat( $op );

		// Don't keep further details about the local operations.
		if ( false !== strpos( $op, '_local' ) ) {
			return;
		}

		if ( ! is_null( $size ) ) {
			$this->size_total += $size;
		}

		if ( ! is_null( $time ) ) {
			$this->time_total += $time;
		}

		$keys = $this->strip_memcached_keys( $keys );

		if ( $time > $this->slow_op_microseconds && 'get_multi' !== $op ) {
			$this->increment_stat( 'slow-ops' );

			/** @psalm-var string|null $backtrace */
			$backtrace                     = function_exists( 'wp_debug_backtrace_summary' ) ? wp_debug_backtrace_summary() : null; // phpcs:ignore
			$this->group_ops['slow-ops'][] = array( $op, $keys, $size, $time, $comment, $group, $backtrace );
		}

		$this->group_ops[ $group ][] = array( $op, $keys, $size, $time, $comment );
	}

	/**
	 * Increment the stat counter for a memcached operation.
	 *
	 * @param string $field The stat field/group being incremented.
	 * @param int $num Amount to increment by.
	 *
	 * @return void
	 */
	public function increment_stat( $field, $num = 1 ) {
		if ( ! isset( $this->stats[ $field ] ) ) {
			$this->stats[ $field ] = $num;
		} else {
			$this->stats[ $field ] += $num;
		}
	}

	/*
	|--------------------------------------------------------------------------
	| Utils.
	|--------------------------------------------------------------------------
	*/

	/**
	 * Key format: key_salt:flush_number:table_prefix:key_name
	 *
	 * We want to strip the `key_salt:flush_number` part to not leak the memcached keys.
	 * If `key_salt` is set we strip `'key_salt:flush_number`, otherwise just strip the `flush_number` part.
	 *
	 * @param string|string[] $keys
	 * @return string|string[]
	 */
	public function strip_memcached_keys( $keys ) {
		$keys = is_array( $keys ) ? $keys : [ $keys ];

		foreach ( $keys as $index => $value ) {
			$offset = 0;

			// Strip off the key salt piece.
			if ( ! empty( $this->key_salt ) ) {
				$salt_piece = strpos( $value, ':' );
				$offset     = false === $salt_piece ? 0 : $salt_piece + 1;
			}

			// Strip off the flush number.
			$flush_piece    = strpos( $value, ':', $offset );
			$start          = false === $flush_piece ? $offset : $flush_piece;
			$keys[ $index ] = substr( $value, $start + 1 );
		}

		if ( 1 === count( $keys ) ) {
			return $keys[0];
		}

		return $keys;
	}

	/*
	|--------------------------------------------------------------------------
	| Stats markup output.
	|--------------------------------------------------------------------------
	*/

	/**
	 * Returns the collected raw stats.
	 */
	public function get_stats(): array {
		$stats = [
			'operation_counts' => $this->stats,
			'operations'       => [],
			'groups'           => [],
			'slow-ops'         => [],
			'slow-ops-groups'  => [],
			'totals'           => [
				'query_time' => $this->time_total,
				'size'       => $this->size_total,
			],
		];

		foreach ( $this->group_ops as $cache_group => $dataset ) {
			$cache_group = empty( $cache_group ) ? 'default' : $cache_group;

			foreach ( $dataset as $data ) {
				$operation = $data[0];
				$op        = [
					'key'    => $data[1],
					'size'   => $data[2],
					'time'   => $data[3],
					'group'  => $cache_group,
					'result' => $data[4],
				];

				if ( 'slow-ops' === $cache_group ) {
					$key             = 'slow-ops';
					$groups_key      = 'slow-ops-groups';
					$op['group']     = $data[5];
					$op['backtrace'] = $data[6];
				} else {
					$key        = 'operations';
					$groups_key = 'groups';
				}

				$stats[ $key ][ $operation ][] = $op;
				if ( ! in_array( $op['group'], $stats[ $groups_key ], true ) ) {
					$stats[ $groups_key ][] = $op['group'];
				}
			}
		}

		return $stats;
	}

	public function stats(): void {
		$this->js_toggle();

		$total_ms         = $this->time_total * 1000.0;
		$total_query_time = number_format_i18n( $total_ms, 1 ) . ' ms';

		$total_size = size_format( $this->size_total, 2 );
		$total_size = false === $total_size ? '0 B' : $total_size;

		echo '<h2><span>Total memcached query time:</span>' . esc_html( $total_query_time ) . '</h2>';
		echo "\n";
		echo '<h2><span>Total memcached size:</span>' . esc_html( $total_size ) . '</h2>';
		echo "\n";

		foreach ( $this->stats as $stat => $n ) {
			if ( empty( $n ) ) {
				continue;
			}

			echo '<h2>';
			// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
			echo $this->colorize_debug_line( "$stat $n" );
			echo '</h2>';
		}

		echo "<ul class='debug-menu-links' style='clear:left;font-size:14px;'>\n";
		$groups = array_keys( $this->group_ops );
		usort( $groups, 'strnatcasecmp' );

		$active_group = $groups[0];
		// Always show `slow-ops` first
		if ( in_array( 'slow-ops', $groups ) ) {
			$slow_ops_key = array_search( 'slow-ops', $groups );
			$slow_ops     = $groups[ $slow_ops_key ];
			unset( $groups[ $slow_ops_key ] );
			array_unshift( $groups, $slow_ops );
			$active_group = 'slow-ops';
		}

		$total_ops    = 0;
		$group_titles = array();
		foreach ( $groups as $group ) {
			$group_name    = empty( $group ) ? 'default' : $group;
			$group_size    = array_sum( array_map( fn( $op ) => $op[2] ?? 0, $this->group_ops[ $group ] ) );
			$group_time_ms = (float) array_sum( array_map( fn( $op ) => $op[3] ?? 0.0, $this->group_ops[ $group ] ) ) * 1000.0;

			$group_ops              = count( $this->group_ops[ $group ] );
			$group_size             = size_format( $group_size, 2 );
			$group_time             = number_format_i18n( $group_time_ms, 1 );
			$total_ops             += $group_ops;
			$group_title            = "{$group_name} [$group_ops][$group_size][{$group_time} ms]";
			$group_titles[ $group ] = $group_title;
			echo "\t<li><a href='#' onclick='memcachedToggleVisibility( \"object-cache-stats-menu-target-" . esc_js( $group_name ) . "\", \"object-cache-stats-menu-target-\" );'>" . esc_html( $group_title ) . "</a></li>\n";
		}
		echo "</ul>\n";

		echo "<div id='object-cache-stats-menu-targets'>\n";
		foreach ( $groups as $group ) {
			$group_name = empty( $group ) ? 'default' : $group;

			$current = $active_group == $group ? 'style="display: block"' : 'style="display: none"';
			// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
			echo "<div id='object-cache-stats-menu-target-" . esc_attr( $group_name ) . "' class='object-cache-stats-menu-target' $current>\n";
			echo '<h3>' . esc_html( $group_titles[ $group ] ) . '</h3>' . "\n";
			echo "<pre>\n";
			foreach ( $this->group_ops[ $group ] as $index => $arr ) {
				echo esc_html( sprintf( '%3d ', $index ) );
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
				echo $this->get_group_ops_line( $index, $arr );
			}
			echo "</pre>\n";
			echo '</div>';
		}

		echo '</div>';
	}

	public function js_toggle(): void {
		echo "
		<script>
		function memcachedToggleVisibility( id, hidePrefix ) {
			var element = document.getElementById( id );
			if ( ! element ) {
				return;
			}

			// Hide all element with `hidePrefix` if given. Used to display only one element at a time.
			if ( hidePrefix ) {
				var groupStats = document.querySelectorAll( '[id^=\"' + hidePrefix + '\"]' );
				groupStats.forEach(
					function ( element ) {
						element.style.display = 'none';
					}
				);
			}

			// Toggle the one we clicked.
			if ( 'none' === element.style.display ) {
				element.style.display = 'block';
			} else {
				element.style.display = 'none';
			}
		}
		</script>
		";
	}

	/**
	 * @param string $line
	 * @param string $trailing_html
	 * @return string
	 */
	public function colorize_debug_line( $line, $trailing_html = '' ) {
		$colors = array(
			'get'          => 'green',
			'get_local'    => 'lightgreen',
			'get_multi'    => 'fuchsia',
			'get_multiple' => 'navy',
			'set'          => 'purple',
			'set_local'    => 'orchid',
			'add'          => 'blue',
			'delete'       => 'red',
			'delete_local' => 'tomato',
			'slow-ops'     => 'crimson',
		);

		$cmd = substr( $line, 0, (int) strpos( $line, ' ' ) );

		// Start off with a neutral default color, and use a more specific one if possible.
		$color_for_cmd = isset( $colors[ $cmd ] ) ? $colors[ $cmd ] : 'brown';

		$cmd2 = "<span style='color:" . esc_attr( $color_for_cmd ) . "; font-weight: bold;'>" . esc_html( $cmd ) . '</span>';

		return $cmd2 . esc_html( substr( $line, strlen( $cmd ) ) ) . "$trailing_html\n";
	}

	/**
	 * @param string|int $index
	 * @param array $arr
	 * @psalm-param array{0: string, 1: string|string[], 2: int|null, 3: float|null, 4: string, 5: string, 6: string|null } $arr
	 *
	 * @return string
	 */
	public function get_group_ops_line( $index, $arr ) {
		// operation
		$line = "{$arr[0]} ";

		// keys
		// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
		$json_encoded_key = json_encode( $arr[1] );
		if ( false === $json_encoded_key ) {
			// Fallback to a readable marker so Psalm knows this is always a string.
			$json_encoded_key = '"<unencodable key>"';
		}
		$line .= $json_encoded_key . ' ';

		// comment
		if ( ! empty( $arr[4] ) ) {
			$line .= "{$arr[4]} ";
		}

		// size
		if ( isset( $arr[2] ) ) {
			$size = size_format( $arr[2], 2 );

			if ( $size ) {
				$line .= '(' . $size . ') ';
			}
		}

		// time
		if ( isset( $arr[3] ) ) {
			$ms    = $arr[3] * 1000.0;
			$line .= '(' . number_format_i18n( $ms, 1 ) . ' ms)';
		}

		// backtrace
		$bt_link = '';
		if ( isset( $arr[6] ) ) {
			$key_hash = md5( (string) $index . $json_encoded_key );
			$bt_link  = " <small><a href='#' onclick='memcachedToggleVisibility( \"object-cache-stats-debug-$key_hash\" );'>Toggle Backtrace</a></small>";
			$bt_link .= "<pre id='object-cache-stats-debug-$key_hash' style='display:none'>" . esc_html( $arr[6] ) . '</pre>';
		}

		return $this->colorize_debug_line( $line, $bt_link );
	}
}

#[\AllowDynamicProperties]
// phpcs:ignore Generic.Files.OneObjectStructurePerFile.MultipleFound
class WP_Object_Cache {
	public string $flush_group        = 'WP_Object_Cache';
	public string $global_flush_group = 'WP_Object_Cache_global';
	public string $flush_key          = 'flush_number_v4';

	/**
	* Keep track of flush numbers.
	* The array key is the blog prefix and value is the number.
	* @var array<string,int>
	*/
	public array $flush_number = [];

	public ?int $global_flush_number = null;
	public string $global_prefix     = '';
	public string $blog_prefix       = '';
	public string $key_salt          = '';

	/**
	* Global cache groups (network-wide rather than site-specific).
	* @var string[]
	*/
	public array $global_groups = [];

	/**
	* Non-persistent cache groups (will not write to Memcached servers).
	* @var string[]
	*/
	public array $no_mc_groups = [];

	public int $default_expiration = 0;
	public int $max_expiration     = 2592000; // 30 days

	private WP_Object_Cache_Stats $stats_helper;

	/** @var array<string,string> server_string => server_key */
	private array $redundancy_server_keys = [];

	/** @psalm-var array<string,\Memcached> */
	public array $mc = [];

	/** @psalm-var array<int,\Memcached> */
	public array $default_mcs = [];

	/**
	* @psalm-var array<string, array{value: mixed, found: bool}>
	*/
	public array $cache = [];

	// Stats tracking.
	public array $stats                = [];
	public array $group_ops            = [];
	public int $cache_hits             = 0;
	public int $cache_misses           = 0;
	public float $time_start           = 0;
	public float $time_total           = 0;
	public int $size_total             = 0;
	public float $slow_op_microseconds = 0.005; // 5 ms

	// TODO: Deprecate. These appear to be unused.
	public string $old_flush_key = 'flush_number';
	public bool $cache_enabled   = true;
	public array $stats_callback = [];
	/** @psalm-var array<array{host: string, port: string}> */
	public array $connection_errors = [];

	/**
	* @global array<string,array<string>>|array<int,string>|null $memcached_servers
	* @global string|null $table_prefix
	* @global int|numeric-string $blog_id
	*
	* @psalm-suppress UnsupportedReferenceUsage
	*/
	public function __construct() {
		global $blog_id, $table_prefix, $memcached_servers;

		$this->global_groups = [ $this->global_flush_group ];

		$is_ms = function_exists( 'is_multisite' ) && is_multisite();

		$this->global_prefix = (string) ( $is_ms || ( defined( 'CUSTOM_USER_TABLE' ) && defined( 'CUSTOM_USER_META_TABLE' ) ) ? '' : $table_prefix );
		$this->blog_prefix   = (string) ( $is_ms ? $blog_id : $table_prefix );

		$servers = is_array( $memcached_servers ) ? $memcached_servers : [ 'default' => [ '127.0.0.1:11211' ] ];
		if ( is_int( key( $servers ) ) ) {
			// Normalize "list form" into buckets.
			$servers = [ 'default' => $servers ];
		}

		$this->salt_keys( WP_CACHE_KEY_SALT, true );

		// Build a Memcached pool per bucket, and individual default server pools for redundancy stats/BC.
		$this->mc                = [];
		$this->default_mcs       = [];
		$this->connection_errors = []; // kept for BC, still populated by failure_callback()

		/** @psalm-var array<string,array<string>> $servers */
		foreach ( $servers as $bucket => $addresses ) {
			$bucket_servers = [];

			foreach ( $addresses as $index => $address ) {
				$parsed           = $this->parse_address( $address );
				$server           = [
					'host'   => $parsed['host'],
					'port'   => $parsed['port'],
					'weight' => 1,
				];
				$bucket_servers[] = $server;

				// Prepare individual connections to servers in the default bucket for flush_number redundancy.
				if ( 'default' === $bucket ) {
					// Deprecated in this adapter. As long as no requests are made from these pools, the connections should never be established.
					$this->default_mcs[] = $this->create_connection_pool( 'redundancy-' . $index, [ $server ] );
				}
			}

			$this->mc[ $bucket ] = $this->create_connection_pool( 'bucket-' . $bucket, $bucket_servers );
		}

		// Fallback: ensure a default pool exists even if a config misses it.
		if ( ! isset( $this->mc['default'] ) && ! empty( $this->mc ) ) {
			$first               = reset( $this->mc );
			$this->mc['default'] = $first;
		}

		// After default pool exists, precompute server keys for redundancy by consistent hashing.
		$this->redundancy_server_keys = $this->get_server_keys_for_redundancy();

		$this->stats_helper = new WP_Object_Cache_Stats( $this->key_salt );

		// Also for backwards compatibility since these have been public properties.
		$this->stats                =& $this->stats_helper->stats;
		$this->group_ops            =& $this->stats_helper->group_ops;
		$this->time_total           =& $this->stats_helper->time_total;
		$this->size_total           =& $this->stats_helper->size_total;
		$this->slow_op_microseconds =& $this->stats_helper->slow_op_microseconds;
		$this->cache_hits           =& $this->stats['get'];
		$this->cache_misses         =& $this->stats['add'];
	}

	/*
	|--------------------------------------------------------------------------
	| The main methods used by the cache API.
	|--------------------------------------------------------------------------
	*/

	/**
	 * Adds data to the cache if it doesn't already exist.
	 *
	 * @param int|string $key    What to call the contents in the cache.
	 * @param mixed      $data   The contents to store in the cache.
	 * @param string     $group  Optional. Where to group the cache contents. Default 'default'.
	 * @param int        $expire Optional. When to expire the cache contents, in seconds.
	 *                           Default 0 (no expiration).
	 * @return bool True on success, false on failure or if cache key and group already exist.
	 */
	public function add( $key, $data, $group = 'default', $expire = 0 ) {
		$key = $this->key( $key, $group );

		if ( is_object( $data ) ) {
			$data = clone $data;
		}

		if ( $this->is_non_persistent_group( $group ) ) {
			if ( isset( $this->cache[ $key ] ) ) {
				return false;
			}

			$this->cache[ $key ] = [
				'value' => $data,
				'found' => false,
			];

			return true;
		}

		if ( isset( $this->cache[ $key ]['value'] ) && false !== $this->cache[ $key ]['value'] ) {
			return false;
		}

		$expire = $this->get_expiration( $expire );
		$size   = $this->get_data_size( $data );

		$this->timer_start();
		$mc      = $this->get_mc( $group );
		$result  = $mc->add( $this->normalize_key( $key ), $data, $expire );
		$elapsed = $this->timer_stop();

		$comment = '';
		if ( isset( $this->cache[ $key ] ) ) {
			$comment .= ' [lc already]';
		}

		if ( false === $result ) {
			$comment .= ' [mc already]';
		}

		$this->group_ops_stats( 'add', $key, $group, $size, $elapsed, $comment );

		if ( $result ) {
			$this->cache[ $key ] = [
				'value' => $data,
				'found' => true,
			];
		} elseif ( isset( $this->cache[ $key ]['value'] ) && false === $this->cache[ $key ]['value'] ) {
			/*
				* Here we unset local cache if remote add failed and local cache value is equal to `false` in order
				* to update the local cache anytime we get a new information from remote server. This way, the next
				* cache get will go to remote server and will fetch recent data.
				*/
			unset( $this->cache[ $key ] );
		}

		return $result;
	}

	/**
	 * Adds multiple values to the cache in one call.
	 *
	 * @param mixed[]  $data   Array of keys and values to be added.
	 * @param string $group  Optional. Where the cache contents are grouped. Default empty.
	 * @param int    $expire Optional. When to expire the cache contents, in seconds.
	 *                       Default 0 (no expiration).
	 * @return bool[] Array of return values, grouped by key. Each value is either
	 *                true on success, or false on failure or if cache key and group already exist.
	 */
	public function add_multiple( $data, $group = '', $expire = 0 ) {
		$result = [];

		/** @psalm-suppress MixedAssignment - $value is unknown/mixed */
		foreach ( $data as $key => $value ) {
			$result[ $key ] = $this->add( $key, $value, $group, $expire );
		}

		return $result;
	}

	/**
	 * Replaces the contents in the cache, if contents already exist.
	 *
	 * @param int|string $key    What to call the contents in the cache.
	 * @param mixed      $data   The contents to store in the cache.
	 * @param string     $group  Optional. Where to group the cache contents. Default 'default'.
	 * @param int        $expire Optional. When to expire the cache contents, in seconds.
	 *                           Default 0 (no expiration).
	 * @return bool True if contents were replaced, false on failure or if the original value did not exist.
	 */
	public function replace( $key, $data, $group = 'default', $expire = 0 ) {
		$key = $this->key( $key, $group );

		if ( is_object( $data ) ) {
			$data = clone $data;
		}

		if ( $this->is_non_persistent_group( $group ) ) {
			if ( ! isset( $this->cache[ $key ] ) ) {
				return false;
			}

			$this->cache[ $key ]['value'] = $data;
			return true;
		}

		$expire = $this->get_expiration( $expire );
		$size   = $this->get_data_size( $data );

		$this->timer_start();
		$mc      = $this->get_mc( $group );
		$result  = $mc->replace( $this->normalize_key( $key ), $data, $expire );
		$elapsed = $this->timer_stop();

		$this->group_ops_stats( 'replace', $key, $group, $size, $elapsed );

		if ( $result ) {
			$this->cache[ $key ] = [
				'value' => $data,
				'found' => true,
			];
		} else {
			// Remove from local cache if the replace failed, as it may no longer exist.
			unset( $this->cache[ $key ] );
		}

		return $result;
	}

	/**
	 * Sets the data contents into the cache.
	 *
	 * @param int|string $key    What to call the contents in the cache.
	 * @param mixed      $data   The contents to store in the cache.
	 * @param string     $group  Optional. Where to group the cache contents. Default 'default'.
	 * @param int        $expire Optional. How long until the cahce contents will expire (in seconds).
	 *
	 * @return bool True if contents were set, false if failed.
	 */
	public function set( $key, $data, $group = 'default', $expire = 0 ) {
		$key = $this->key( $key, $group );

		if ( isset( $this->cache[ $key ] ) && 'checkthedatabaseplease' === $this->cache[ $key ]['value'] ) {
			return false;
		}

		if ( is_object( $data ) ) {
			$data = clone $data;
		}

		if ( $this->is_non_persistent_group( $group ) ) {
			$this->group_ops_stats( 'set_local', $key, $group );

			$this->cache[ $key ] = [
				'value' => $data,
				'found' => false,
			];

			return true;
		}

		$expire = $this->get_expiration( $expire );
		$size   = $this->get_data_size( $data );

		$this->timer_start();
		$mc      = $this->get_mc( $group );
		$result  = $mc->set( $this->normalize_key( $key ), $data, $expire );
		$elapsed = $this->timer_stop();

		$this->group_ops_stats( 'set', $key, $group, $size, $elapsed );

		$this->cache[ $key ] = [
			'value' => $data,
			'found' => $result,
		];

		return $result;
	}

	/**
	 * Sets multiple values to the cache in one call.
	 *
	 * @param mixed[] $data Array of key and value to be set.
	 * @param string  $group  Optional. Where the cache contents are grouped. Default empty.
	 * @param int     $expire Optional. When to expire the cache contents, in seconds.
	 *                        Default 0 (no expiration).
	 * @return bool[] Array of return values, grouped by key. Value is true on success, false on failure.
	 */
	public function set_multiple( $data, $group = '', $expire = 0 ) {
		$result = [];

		/** @psalm-suppress MixedAssignment - $value is mixed */
		foreach ( $data as $key => $value ) {
			// TODO: Could try to make Memcached::setMulti() work, though the return structure differs.
			$result[ $key ] = $this->set( $key, $value, $group, $expire );
		}

		return $result;
	}

	/**
	 * Retrieves the cache contents, if it exists.
	 *
	 * @param int|string $key   The key under which the cache contents are stored.
	 * @param string     $group Optional. Where the cache contents are grouped. Default 'default'.
	 * @param bool       $force Optional. Whether to force an update of the local cache
	 *                          from the persistent cache. Default false.
	 * @param bool       $found Optional. Whether the key was found in the cache (passed by reference).
	 *                          Disambiguates a return of false, a storable value. Default null.
	 * @return mixed|false The cache contents on success, false on failure to retrieve contents.
	 */
	public function get( $key, $group = 'default', $force = false, &$found = null ) {
		$key = $this->key( $key, $group );

		if ( $force && $this->is_non_persistent_group( $group ) ) {
			// There's nothing to "force" retrieve.
			$force = false;
		}

		if ( isset( $this->cache[ $key ] ) && ! $force ) {
			/** @psalm-suppress MixedAssignment */
			$value = is_object( $this->cache[ $key ]['value'] ) ? clone $this->cache[ $key ]['value'] : $this->cache[ $key ]['value'];
			$found = $this->cache[ $key ]['found'];

			$this->group_ops_stats( 'get_local', $key, $group, null, null, 'local' );

			return $value;
		}

		// For a non-persistant group, if it's not in local cache then it just doesn't exist.
		if ( $this->is_non_persistent_group( $group ) ) {
			$found = false;
			$this->group_ops_stats( 'get_local', $key, $group, null, null, 'not_in_local' );

			return false;
		}

		$this->timer_start();
		$mc = $this->get_mc( $group );
		/** @psalm-suppress MixedAssignment */
		$value   = $mc->get( $this->normalize_key( $key ) );
		$found   = \Memcached::RES_NOTFOUND !== $mc->getResultCode();
		$elapsed = $this->timer_stop();

		if ( 'checkthedatabaseplease' === $value ) {
			unset( $this->cache[ $key ] );
			$found = false;
			$this->group_ops_stats( 'get', $key, $group, null, $elapsed, 'checkthedatabaseplease' );
			return false;
		}

		$this->cache[ $key ] = [
			'value' => $value,
			'found' => $found,
		];

		if ( $found ) {
			$this->group_ops_stats( 'get', $key, $group, $this->get_data_size( $value ), $elapsed, 'memcache' );
		} else {
			$this->group_ops_stats( 'get', $key, $group, null, $elapsed, 'not_in_memcache' );
		}

		return $value;
	}

	/**
	 * Retrieves multiple values from the cache in one call.
	 *
	 * @param array<string|int> $keys Array of keys under which the cache contents are stored.
	 * @param string $group Optional. Where the cache contents are grouped. Default 'default'.
	 * @param bool   $force Optional. Whether to force an update of the local cache
	 *                      from the persistent cache. Default false.
	 * @return mixed[] Array of return values, grouped by key. Each value is either
	 *                 the cache contents on success, or false on failure.
	 */
	public function get_multiple( $keys, $group = 'default', $force = false ) {
		$uncached_keys = [];
		$return        = [];
		$return_cache  = [];

		if ( $force && $this->is_non_persistent_group( $group ) ) {
			// There's nothing to "force" retrieve.
			$force = false;
		}

		// First, fetch what we can from runtime cache.
		foreach ( $keys as $key ) {
			$cache_key = $this->key( $key, $group );

			if ( isset( $this->cache[ $cache_key ] ) && ! $force ) {
				/** @psalm-suppress MixedAssignment */
				$return[ $key ] = is_object( $this->cache[ $cache_key ]['value'] ) ? clone $this->cache[ $cache_key ]['value'] : $this->cache[ $cache_key ]['value'];

				$this->group_ops_stats( 'get_local', $cache_key, $group, null, null, 'local' );
			} elseif ( $this->is_non_persistent_group( $group ) ) {
				$return[ $key ] = false;
				$this->group_ops_stats( 'get_local', $cache_key, $group, null, null, 'not_in_local' );
			} else {
				$uncached_keys[ $key ] = $cache_key;
			}
		}

		if ( ! empty( $uncached_keys ) ) {
			$mc             = $this->get_mc( $group );
			$keys_to_lookup = array_values( $uncached_keys ); // full cache keys
			$mapped         = $this->normalize_keys_with_mapping( $keys_to_lookup ); // normalized => full

			$this->timer_start();
			$raw = [];
			if ( count( $mapped ) > 1000 ) {
				foreach ( array_chunk( array_keys( $mapped ), 1000, true ) as $chunk ) {
					/** @var array<string,mixed>|false $partial */
					$partial = $mc->getMulti( $chunk );
					if ( ! is_array( $partial ) ) {
						// Match the adapter’s “bail on whole call” behavior if any batch fails.
						$raw = false;
						break;
					}
					$raw = array_merge( $raw, $partial );
				}
			} else {
				/** @var array<string,mixed>|false $raw */
				$raw = $mc->getMulti( array_keys( $mapped ) );
			}
			$elapsed = $this->timer_stop();

			/** @var array<string, scalar|array|object|null> $values */
			$values = []; // full_key => value
			if ( is_array( $raw ) ) {
				/** @var array<string, scalar|array|object|null> $raw */
				foreach ( $raw as $normalized => $val ) {
					$full            = $mapped[ $normalized ] ?? $normalized;
					$values[ $full ] = $val;
				}
			}

			foreach ( $uncached_keys as $key => $cache_key ) {
				$found = array_key_exists( $cache_key, $values );
				/** @psalm-suppress MixedAssignment */
				$value = $found ? $values[ $cache_key ] : false;

				// Treat the remote invalidation special value as a miss and do not cache it locally.
				if ( $found && 'checkthedatabaseplease' === $value ) {
					$return[ $key ] = false;
					// Ensure we do NOT persist a runtime entry for this key.
					unset( $return_cache[ $cache_key ] );
					continue;
				}

				/** @psalm-suppress MixedAssignment */
				$return[ $key ]             = $value;
				$return_cache[ $cache_key ] = [
					'value' => $value,
					'found' => $found,
				];
			}

			$this->group_ops_stats( 'get_multiple', array_values( $uncached_keys ), $group, $this->get_data_size( array_values( $values ) ), $elapsed );
		}

		$this->cache = array_merge( $this->cache, $return_cache );
		return $return;
	}

	/**
	 * Retrieves multiple values from the cache in one call.
	 *
	 * @param array<string, array<string|int>> $groups  Array of keys, indexed by group.
	 *                                                  Example: $groups['group-name'] = [ 'key1', 'key2' ]
	 *
	 * @return mixed[] Array of return values, grouped by key. Each value is either
	 *                 the cache contents on success, or false on failure.
	 */
	public function get_multi( $groups ) {
		$return = [];

		foreach ( $groups as $group => $keys ) {
			$results = $this->get_multiple( $keys, $group );

			foreach ( $keys as $key ) {
				// This feels like a bug, as the full cache key is not useful to consumers. But alas, should be deprecating this method soon anyway.
				$cache_key = $this->key( $key, $group );

				/** @psalm-suppress MixedAssignment */
				$return[ $cache_key ] = isset( $results[ $key ] ) ? $results[ $key ] : false;
			}
		}

		return $return;
	}

	/**
	 * Removes the contents of the cache key in the group.
	 *
	 * @param int|string $key   What the contents in the cache are called.
	 * @param string     $group Optional. Where the cache contents are grouped. Default 'default'.
	 *
	 * @return bool True on success, false on failure or if the contents were not deleted.
	 */
	public function delete( $key, $group = 'default' ) {
		$key = $this->key( $key, $group );

		if ( $this->is_non_persistent_group( $group ) ) {
			$result = isset( $this->cache[ $key ] );
			unset( $this->cache[ $key ] );

			return $result;
		}

		$this->timer_start();
		$mc      = $this->get_mc( $group );
		$deleted = $mc->delete( $this->normalize_key( $key ) );
		$elapsed = $this->timer_stop();

		$this->group_ops_stats( 'delete', $key, $group, null, $elapsed );

		// Remove from local cache regardless of the result.
		unset( $this->cache[ $key ] );

		return $deleted;
	}

	/**
	 * Deletes multiple values from the cache in one call.
	 *
	 * @param array<string|int> $keys  Array of keys to be deleted.
	 * @param string $group Optional. Where the cache contents are grouped. Default empty.
	 * @return bool[] Array of return values, grouped by key. Each value is either
	 *                true on success, or false if the contents were not deleted.
	 */
	public function delete_multiple( $keys, $group = '' ) {
		if ( $this->is_non_persistent_group( $group ) ) {
			$return = [];

			foreach ( $keys as $key ) {
				$cache_key = $this->key( $key, $group );

				$deleted = isset( $this->cache[ $cache_key ] );
				unset( $this->cache[ $cache_key ] );

				$return[ $key ] = $deleted;
			}

			return $return;
		}

		$mapped_keys = $this->map_keys( $keys, $group ); // full_key => original_key

		$this->timer_start();
		$mc = $this->get_mc( $group );

		// normalize full keys for memcached call
		$normalized_map = $this->normalize_keys_with_mapping( array_keys( $mapped_keys ) ); // normalized => full

		/** @psalm-var array<string, true|int> $results */
		$results = $mc->deleteMulti( array_keys( $normalized_map ) );
		$elapsed = $this->timer_stop();

		$this->group_ops_stats( 'delete_multiple', array_keys( $mapped_keys ), $group, null, $elapsed );

		$return = [];
		foreach ( $results as $norm_key => $result_value ) {
			$full_key     = $normalized_map[ $norm_key ] ?? $norm_key;
			$original_key = $mapped_keys[ $full_key ] ?? $full_key;

			// deleteMulti returns true on success, or Memcached::RES_* int on failure
			$return[ $original_key ] = ( true === $result_value );

			unset( $this->cache[ $full_key ] );
		}

		return $return;
	}

	/**
	 * Increments numeric cache item's value.
	 *
	 * @param int|string $key    The cache key to increment.
	 * @param int        $offset Optional. The amount by which to increment the item's value.
	 *                           Default 1.
	 * @param string     $group  Optional. The group the key is in. Default 'default'.
	 * @return int|false The item's new value on success, false on failure.
	 */
	public function incr( $key, $offset = 1, $group = 'default' ) {
		$key = $this->key( $key, $group );

		if ( $this->is_non_persistent_group( $group ) ) {
			if ( ! isset( $this->cache[ $key ] ) || ! is_int( $this->cache[ $key ]['value'] ) ) {
				return false;
			}

			$this->cache[ $key ]['value'] += $offset;
			return $this->cache[ $key ]['value'];
		}

		$this->timer_start();
		$mc          = $this->get_mc( $group );
		$incremented = $mc->increment( $this->normalize_key( $key ), $offset );
		$elapsed     = $this->timer_stop();

		$this->group_ops_stats( 'increment', $key, $group, null, $elapsed );

		$this->cache[ $key ] = [
			'value' => $incremented,
			'found' => false !== $incremented,
		];

		return $incremented;
	}

	/**
	 * Decrements numeric cache item's value.
	 *
	 * @param int|string $key    The cache key to decrement.
	 * @param int        $offset Optional. The amount by which to decrement the item's value.
	 *                           Default 1.
	 * @param string     $group  Optional. The group the key is in. Default 'default'.
	 * @return int|false The item's new value on success, false on failure.
	 */
	public function decr( $key, $offset = 1, $group = 'default' ) {
		$key = $this->key( $key, $group );

		if ( $this->is_non_persistent_group( $group ) ) {
			if ( ! isset( $this->cache[ $key ] ) || ! is_int( $this->cache[ $key ]['value'] ) ) {
				return false;
			}

			$new_value = $this->cache[ $key ]['value'] - $offset;
			if ( $new_value < 0 ) {
				$new_value = 0;
			}

			$this->cache[ $key ]['value'] = $new_value;
			return $this->cache[ $key ]['value'];
		}

		$this->timer_start();
		$mc          = $this->get_mc( $group );
		$decremented = $mc->decrement( $this->normalize_key( $key ), $offset );
		$elapsed     = $this->timer_stop();

		$this->group_ops_stats( 'decrement', $key, $group, null, $elapsed );

		$this->cache[ $key ] = [
			'value' => $decremented,
			'found' => false !== $decremented,
		];

		return $decremented;
	}

	/**
	 * Clears the object cache of all data.
	 *
	 * Purposely does not use the memcached flush method,
	 * as that acts on the entire memcached server, affecting all sites.
	 * Instead, we rotate the key prefix for the current site,
	 * along with the global key when flushing the main site.
	 *
	 * @return true Always returns true.
	 */
	public function flush() {
		$this->cache = [];

		$flush_number = $this->new_flush_number();

		$this->rotate_site_keys( $flush_number );
		if ( is_main_site() ) {
			$this->rotate_global_keys( $flush_number );
		}

		return true;
	}

	/**
	 * Unsupported: Removes all cache items in a group.
	 *
	 * @param string $_group Name of group to remove from cache.
	 * @return bool Returns false, as there is no support for group flushes.
	 */
	public function flush_group( $_group ) {
		return false;
	}

	/**
	 * Removes all cache items from the in-memory runtime cache.
	 * Also reset the local stat-related tracking for individual operations.
	 *
	 * @return true Always returns true.
	 */
	public function flush_runtime() {
		$this->cache     = [];
		$this->group_ops = [];

		return true;
	}

	/**
	 * Sets the list of global cache groups.
	 *
	 * @param string|string[] $groups List of groups that are global.
	 * @return void
	 */
	public function add_global_groups( $groups ) {
		if ( ! is_array( $groups ) ) {
			$groups = (array) $groups;
		}

		$this->global_groups = array_unique( array_merge( $this->global_groups, $groups ) );
	}

	/**
	 * Sets the list of non-persistent groups.
	 *
	 * @param string|string[] $groups List of groups that will not be saved to persistent cache.
	 * @return void
	 */
	public function add_non_persistent_groups( $groups ) {
		if ( ! is_array( $groups ) ) {
			$groups = (array) $groups;
		}

		$this->no_mc_groups = array_unique( array_merge( $this->no_mc_groups, $groups ) );
	}

	/**
	 * Switches the internal blog ID.
	 *
	 * This changes the blog ID used to create keys in blog specific groups.
	 *
	 * @param int $blog_id Blog ID.
	 * @return void
	 */
	public function switch_to_blog( $blog_id ) {
		global $table_prefix;

		/** @psalm-suppress RedundantCastGivenDocblockType **/
		$blog_id = (int) $blog_id;

		$this->blog_prefix = (string) ( is_multisite() ? $blog_id : $table_prefix );
	}

	/**
	 * Close the connections.
	 *
	 * @return bool
	 */
	public function close() {
		// Memcached::quit() closes persistent connections, which we don't want to do.
		return true;
	}

	/*
	|--------------------------------------------------------------------------
	| Internal methods that deal with flush numbers, the pseudo-cache-flushing mechanic.
	| Public methods here may be deprecated & made private in the future.
	|--------------------------------------------------------------------------
	*/

	/**
	 * Get the flush number prefix, used for creating the key string.
	 *
	 * @param string|int $group
	 * @return string
	 */
	public function flush_prefix( $group ) {
		if ( $group === $this->flush_group || $group === $this->global_flush_group ) {
			// Never flush the flush numbers.
			$number = '_';
		} elseif ( false !== array_search( $group, $this->global_groups ) ) {
			$number = $this->get_global_flush_number();
		} else {
			$number = $this->get_blog_flush_number();
		}

		return $number . ':';
	}

	/**
	 * Get the global group flush number.
	 *
	 * @return int
	 */
	public function get_global_flush_number() {
		if ( ! isset( $this->global_flush_number ) ) {
			$this->global_flush_number = $this->get_flush_number( $this->global_flush_group );
		}

		return $this->global_flush_number;
	}

	/**
	 * Get the blog's flush number.
	 *
	 * @return int
	 */
	public function get_blog_flush_number() {
		if ( ! isset( $this->flush_number[ $this->blog_prefix ] ) ) {
			$this->flush_number[ $this->blog_prefix ] = $this->get_flush_number( $this->flush_group );
		}

		return $this->flush_number[ $this->blog_prefix ];
	}

	/**
	 * Get the flush number for a specific group.
	 *
	 * @param string $group
	 * @return int
	 */
	public function get_flush_number( $group ) {
		$flush_number = $this->get_max_flush_number( $group );

		if ( empty( $flush_number ) ) {
			// If there was no flush number anywhere, make a new one. This flushes the cache.
			$flush_number = $this->new_flush_number();
			$this->set_flush_number( $flush_number, $group );
		}

		return $flush_number;
	}

	/**
	 * Set the flush number for a specific group.
	 *
	 * @param int $value
	 * @param string $group
	 * @return void
	 */
	public function set_flush_number( $value, $group ) {
		$key    = $this->key( $this->flush_key, $group );
		$expire = 0;
		$size   = 19; // size of the microsecond timestamp serialized

		$this->timer_start();
		$mc = $this->mc['default'] ?? null;
		if ( $mc ) {
			foreach ( $this->redundancy_server_keys as $server_string => $server_key ) {
				$mc->setByKey( $server_key, $key, $value, $expire );
			}
		}
		$elapsed = $this->timer_stop();

		$replication_servers_count = max( count( $this->default_mcs ), 1 );
		$average_time_elapsed      = $elapsed / (float) $replication_servers_count;
		foreach ( $this->default_mcs as $_default_mc ) {
			$this->group_ops_stats( 'set_flush_number', $key, $group, $size, $average_time_elapsed, 'replication' );
		}
	}

	/**
	 * Get the highest flush number from all default servers, replicating if needed.
	 *
	 * @param string $group
	 * @return int|false
	 */
	public function get_max_flush_number( $group ) {
		$key  = $this->key( $this->flush_key, $group );
		$size = 19; // size of the microsecond timestamp serialized

		$this->timer_start();
		$values = [];
		$mc     = $this->mc['default'] ?? null;
		if ( $mc ) {
			foreach ( $this->redundancy_server_keys as $server_string => $server_key ) {
				/** @psalm-suppress MixedAssignment */
				$values[ $server_string ] = $mc->getByKey( $server_key, $key );
			}
		}
		$elapsed = $this->timer_stop();

		$replication_servers_count = max( count( $this->default_mcs ), 1 );
		$average_time_elapsed      = $elapsed / (float) $replication_servers_count;

		/** @psalm-suppress MixedAssignment */
		foreach ( $values as $result ) {
			if ( false === $result ) {
				$this->group_ops_stats( 'get_flush_number', $key, $group, null, $average_time_elapsed, 'not_in_memcache' );
			} else {
				$this->group_ops_stats( 'get_flush_number', $key, $group, $size, $average_time_elapsed, 'memcache' );
			}
		}

		$values = array_map( 'intval', $values );
		/** @psalm-suppress ArgumentTypeCoercion */
		$max = max( $values );

		if ( $max <= 0 ) {
			return false;
		}

		$servers_to_update = [];
		foreach ( $values as $server_string => $value ) {
			if ( $value < $max ) {
				$servers_to_update[] = $server_string;
			}
		}

		// Replicate to servers not having the max.
		if ( ! empty( $servers_to_update ) ) {
			$expire = 0;

			$this->timer_start();
			if ( $mc ) {
				foreach ( $this->redundancy_server_keys as $server_string => $server_key ) {
					if ( in_array( $server_string, $servers_to_update, true ) ) {
						$mc->setByKey( $server_key, $key, $max, $expire );
					}
				}
			}
			$elapsed = $this->timer_stop();

			$average_time_elapsed = $elapsed / (float) count( $servers_to_update );
			foreach ( $servers_to_update as $updated_server ) {
				$this->group_ops_stats( 'set_flush_number', $key, $group, $size, $average_time_elapsed, 'replication_repair' );
			}
		}

		return $max;
	}

	/**
	* Rotate the flush number for the site/blog.
	*
	* @param ?int $flush_number
	* @return void
	*/
	public function rotate_site_keys( $flush_number = null ) {
		if ( is_null( $flush_number ) ) {
			$flush_number = $this->new_flush_number();
		}

		$this->set_flush_number( $flush_number, $this->flush_group );
		$this->flush_number[ $this->blog_prefix ] = $flush_number;
	}

	/**
	* Rotate the global flush number.
	*
	* @param ?int $flush_number
	* @return void
	*/
	public function rotate_global_keys( $flush_number = null ) {
		if ( is_null( $flush_number ) ) {
			$flush_number = $this->new_flush_number();
		}

		$this->set_flush_number( $flush_number, $this->global_flush_group );
		$this->global_flush_number = $flush_number;
	}

	/**
	* Generate new flush number.
	*
	* @return int
	*/
	public function new_flush_number(): int {
		return intval( microtime( true ) * 1e6 );
	}

	/*
	|--------------------------------------------------------------------------
	| Utility methods. Internal use only.
	| Public methods here may be deprecated & made private in the future.
	|--------------------------------------------------------------------------
	*/

	/**
	 * Generate the key we'll use to interact with memcached.
	 * Note: APCU requires this to be public right now.
	 *
	 * @param int|string $key
	 * @param int|string $group
	 * @return string
	 */
	public function key( $key, $group ): string {
		if ( empty( $group ) ) {
			$group = 'default';
		}

		$result = sprintf(
			'%s%s%s:%s:%s',
			$this->key_salt,
			$this->flush_prefix( $group ),
			array_search( $group, $this->global_groups ) !== false ? $this->global_prefix : $this->blog_prefix,
			$group,
			$key
		);

		return (string) preg_replace( '/\\s+/', '', $result );
	}

	/**
	 * Map the full cache key to the original key.
	 *
	 * @param array<int|string> $keys
	 * @param string $group
	 * @return array<string, int|string>
	 */
	protected function map_keys( $keys, $group ): array {
		$results = [];

		foreach ( $keys as $key ) {
			$results[ $this->key( $key, $group ) ] = $key;
		}

		return $results;
	}

	/**
	 * Get the memcached instance for the specified group.
	 *
	 * @param int|string $group
	 * @return Memcached
	 */
	public function get_mc( $group ) {
		if ( isset( $this->mc[ $group ] ) ) {
			return $this->mc[ $group ];
		}

		return $this->mc['default'];
	}

	/**
	 * Sanitize the expiration time.
	 *
	 * @psalm-param int|numeric-string|float $expire
	 */
	private function get_expiration( $expire ): int {
		$expire = intval( $expire );
		if ( $expire <= 0 || $expire > $this->max_expiration ) {
			$expire = $this->default_expiration;
		}

		return $expire;
	}

	/**
	 * Check if the group is set up for non-persistent cache.
	 *
	 * @param string|int $group
	 * @return bool
	 */
	private function is_non_persistent_group( $group ) {
		return in_array( $group, $this->no_mc_groups, true );
	}

	/**
	 * Estimate the (uncompressed) data size.
	 *
	 * @param mixed $data
	 * @return int
	 * @psalm-return 0|positive-int
	 */
	public function get_data_size( $data ): int {
		if ( is_string( $data ) ) {
			return strlen( $data );
		}

		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
		return strlen( serialize( $data ) );
	}

	/**
	 * Sets the key salt property.
	 *
	 * @param mixed $key_salt
	 * @param bool $add_mc_prefix
	 * @return void
	 */
	public function salt_keys( $key_salt, $add_mc_prefix = false ) {
		$key_salt = is_string( $key_salt ) && strlen( $key_salt ) ? $key_salt : '';
		$key_salt = $add_mc_prefix ? $key_salt . '_mc' : $key_salt;

		$this->key_salt = empty( $key_salt ) ? '' : $key_salt . ':';
	}

	public function timer_start(): bool {
		$this->time_start = microtime( true );
		return true;
	}

	public function timer_stop(): float {
		return microtime( true ) - $this->time_start;
	}

	/**
	 * TODO: Deprecate.
	 *
	 * @param string $host
	 * @param string $port
	 */
	public function failure_callback( $host, $port ): void {
		$this->connection_errors[] = array(
			'host' => $host,
			'port' => $port,
		);
	}

	/*
	|--------------------------------------------------------------------------
	| Memcached Internals (Inlined from adapter)
	|--------------------------------------------------------------------------
	*/
	/**
	 * Servers and configurations are persisted between requests.
	 * Only add servers when configuration changed.
	 *
	 * @param string $name
	 * @psalm-param array<int, array{host: string, port: int, weight: int}> $servers
	 * @return \Memcached
	 */
	private function create_connection_pool( string $name, array $servers ): \Memcached {
		$mc = new \Memcached( $name );

		// Servers and configurations are persisted between requests.
		/** @psalm-var array<int,array{host: string, port: int, type: string}> $existing_servers */
		$existing_servers = $mc->getServerList();

		// Check if the servers have changed since they were registered.
		$needs_refresh = count( $existing_servers ) !== count( $servers );
		foreach ( $servers as $index => $server ) {
			$existing_host = $existing_servers[ $index ]['host'] ?? null;
			$existing_port = $existing_servers[ $index ]['port'] ?? null;

			if ( $existing_host !== $server['host'] || $existing_port !== $server['port'] ) {
				$needs_refresh = true;
			}
		}

		if ( $needs_refresh ) {
			$mc->resetServerList();

			$servers_to_add = [];
			foreach ( $servers as $server ) {
				$servers_to_add[] = [ $server['host'], $server['port'], $server['weight'] ];
			}

			$mc->addServers( $servers_to_add );
			$mc->setOptions( $this->get_config_options() );
		}

		return $mc;
	}

	/**
	 * @param string $address
	 * @psalm-return array{host: string, port: int}
	 */
	private function parse_address( string $address ): array {
		$default_port = 11211;

		if ( 'unix://' === substr( $address, 0, 7 ) ) {
			// Memcached wants unix:// stripped.
			$host = substr( $address, 7 );
			$port = 0;
		} else {
			$items = explode( ':', $address, 2 );
			$host  = $items[0];
			$port  = isset( $items[1] ) ? intval( $items[1] ) : $default_port;
		}

		return [
			'host' => $host,
			'port' => $port,
		];
	}

	private function get_config_options(): array {
		/**
		 * @psalm-suppress RedundantCondition
		 * @psalm-suppress TypeDoesNotContainType
		 */
		$serializer = \Memcached::HAVE_IGBINARY && extension_loaded( 'igbinary' )
			? \Memcached::SERIALIZER_IGBINARY
			: \Memcached::SERIALIZER_PHP;

		return [
			\Memcached::OPT_BINARY_PROTOCOL => false,
			\Memcached::OPT_SERIALIZER      => $serializer,
			\Memcached::OPT_CONNECT_TIMEOUT => 1000,
			\Memcached::OPT_COMPRESSION     => true,
			\Memcached::OPT_TCP_NODELAY     => true,
		];
	}

	/**
	 * We want a unique string that maps to each default server so we can use setByKey/getByKey
	 * to talk to servers individually (for flush number redundancy).
	 *
	 * @psalm-return array<string, string> // "host:port" => server_key
	 */
	private function get_server_keys_for_redundancy(): array {
		if ( ! isset( $this->mc['default'] ) ) {
			return [];
		}
		$default_pool = $this->mc['default'];
		$servers      = $default_pool->getServerList();

		$server_keys = [];
		for ( $i = 0; $i < 1000; $i++ ) {
			$test_key = 'redundancy_key_' . $i;

			/** @psalm-var array{host: string, port: int, weight: int}|false $result */
			$result = $default_pool->getServerByKey( $test_key );
			if ( ! $result ) {
				continue;
			}

			$server_string = $result['host'] . ':' . $result['port'];
			if ( ! isset( $server_keys[ $server_string ] ) ) {
				$server_keys[ $server_string ] = $test_key;
			}
			// Keep going until every server is accounted for (capped at 1000 attempts - which is incredibly unlikely unless there are tons of servers).
			if ( count( $server_keys ) === count( $servers ) ) {
				break;
			}
		}

		return $server_keys;
	}

	/**
	 * Memcached strictly enforces key length <= 250.
	 * We shorten long keys deterministically.
	 *
	 * @psalm-return string
	 */
	private function normalize_key( string $key ): string {
		if ( strlen( $key ) <= 250 ) {
			return $key;
		}
		return substr( $key, 0, 200 ) . ':truncated:' . md5( $key );
	}

	/**
	 * Reduce key lengths while keeping a map normalized_key => original_key.
	 *
	 * @param string[] $keys
	 * @psalm-return array<string,string>
	 */
	private function normalize_keys_with_mapping( array $keys ): array {
		$mapped = [];
		foreach ( $keys as $key ) {
			$mapped[ $this->normalize_key( $key ) ] = $key;
		}
		return $mapped;
	}

	/*
	|--------------------------------------------------------------------------
	| Stat-related tracking & output.
	| A lot of the below should be deprecated/removed in the future.
	|--------------------------------------------------------------------------
	*/

	/**
	 * Echoes the stats of the caching operations that have taken place.
	 * Ideally this should be the only method left public in this section.
	 *
	 * @return void Outputs the info directly, nothing is returned.
	 */
	public function stats() {
		$this->stats_helper->stats();
	}

	/**
	* Sets the key salt property.
	*
	* @param string $op The operation taking place, such as "set" or "get".
	* @param string|string[] $keys The memcached key/keys involved in the operation.
	* @param string $group The group the keys are in.
	* @param ?int $size The size of the data invovled in the operation.
	* @param ?float $time The time the operation took.
	* @param string $comment Extra notes about the operation.
	*
	* @return void
	*/
	public function group_ops_stats( $op, $keys, $group, $size = null, $time = null, $comment = '' ) {
		$this->stats_helper->group_ops_stats( $op, $keys, $group, $size, $time, $comment );
	}

	/**
	 * Returns the collected raw stats.
	 */
	public function get_stats(): array {
		return $this->stats_helper->get_stats();
	}

	/**
	 * @param string $field The stat field/group being incremented.
	 * @param int $num Amount to increment by.
	 */
	public function increment_stat( $field, $num = 1 ): void {
		$this->stats_helper->increment_stat( $field, $num );
	}

	/**
	 * @param string|string[] $keys
	 * @return string|string[]
	 */
	public function strip_memcached_keys( $keys ) {
		return $this->stats_helper->strip_memcached_keys( $keys );
	}

	public function js_toggle(): void {
		$this->stats_helper->js_toggle();
	}

	/**
	 * @param string $line
	 * @param string $trailing_html
	 * @return string
	 */
	public function colorize_debug_line( $line, $trailing_html = '' ) {
		return $this->stats_helper->colorize_debug_line( $line, $trailing_html );
	}

	/**
	 * @param string|int $index
	 * @param array $arr
	 * @psalm-param array{0: string, 1: string|string[], 2: int|null, 3: float|null, 4: string, 5: string, 6: string|null } $arr
	 *
	 * @return string
	 */
	public function get_group_ops_line( $index, $arr ) {
		return $this->stats_helper->get_group_ops_line( $index, $arr );
	}
}
/**
 * Sets up Object Cache Global and assigns it.
 *
 * @global WP_Object_Cache $wp_object_cache
 * @return void
 */
function wp_cache_init() {
	// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
	$GLOBALS['wp_object_cache'] = new WP_Object_Cache();
}

/**
 * Adds data to the cache, if the cache key doesn't already exist.
 *
 * @global WP_Object_Cache $wp_object_cache Object cache global instance.
 *
 * @param int|string $key    The cache key to use for retrieval later.
 * @param mixed      $data   The data to add to the cache.
 * @param string     $group  Optional. The group to add the cache to. Enables the same key
 *                           to be used across groups. Default empty.
 * @param int        $expire Optional. When the cache data should expire, in seconds.
 *                           Default 0 (no expiration).
 * @return bool True on success, false if cache key and group already exist.
 */
function wp_cache_add( $key, $data, $group = '', $expire = 0 ) {
	global $wp_object_cache;

	// Falsey values for alloptions should never be cached.
	if ( 'alloptions' === $key && 'options' === $group && ! $data ) {
		return false;
	}

	/** @psalm-suppress RedundantCastGivenDocblockType */
	return $wp_object_cache->add( $key, $data, $group, (int) $expire );
}

/**
 * Adds multiple values to the cache in one call.
 *
 * @global WP_Object_Cache $wp_object_cache Object cache global instance.
 *
 * @param array  $data   Array of keys and values to be set.
 * @param string $group  Optional. Where the cache contents are grouped. Default empty.
 * @param int    $expire Optional. When to expire the cache contents, in seconds.
 *                       Default 0 (no expiration).
 * @return bool[] Array of return values, grouped by key. Each value is either
 *                true on success, or false if cache key and group already exist.
 * @psalm-param mixed[] $data
 */
function wp_cache_add_multiple( array $data, $group = '', $expire = 0 ) {
	global $wp_object_cache;

	/** @psalm-suppress RedundantCastGivenDocblockType */
	return $wp_object_cache->add_multiple( $data, $group, (int) $expire );
}

/**
 * Replaces the contents of the cache with new data.
 *
 * @global WP_Object_Cache $wp_object_cache Object cache global instance.
 *
 * @param int|string $key    The key for the cache data that should be replaced.
 * @param mixed      $data   The new data to store in the cache.
 * @param string     $group  Optional. The group for the cache data that should be replaced.
 *                           Default empty.
 * @param int        $expire Optional. When to expire the cache contents, in seconds.
 *                           Default 0 (no expiration).
 * @return bool True if contents were replaced, false if original value does not exist.
 * @psalm-suppress RedundantCast
 */
function wp_cache_replace( $key, $data, $group = '', $expire = 0 ) {
	global $wp_object_cache;

	return $wp_object_cache->replace( $key, $data, $group, (int) $expire );
}

/**
 * Saves the data to the cache.
 *
 * Differs from wp_cache_add() and wp_cache_replace() in that it will always write data.
 *
 * @global WP_Object_Cache $wp_object_cache Object cache global instance.
 *
 * @param int|string $key    The cache key to use for retrieval later.
 * @param mixed      $data   The contents to store in the cache.
 * @param string     $group  Optional. Where to group the cache contents. Enables the same key
 *                           to be used across groups. Default empty.
 * @param int        $expire Optional. When to expire the cache contents, in seconds.
 *                           Default 0 (no expiration).
 * @return bool True on success, false on failure.
 */
function wp_cache_set( $key, $data, $group = '', $expire = 0 ) {
	global $wp_object_cache;

	if ( ! defined( 'WP_INSTALLING' ) ) {
		/** @psalm-suppress RedundantCastGivenDocblockType */
		return $wp_object_cache->set( $key, $data, $group, (int) $expire );
	}

	return $wp_object_cache->delete( $key, $group );
}

/**
 * Sets multiple values to the cache in one call.
 *
 * @global WP_Object_Cache $wp_object_cache Object cache global instance.
 *
 * @param array  $data   Array of keys and values to be set.
 * @param string $group  Optional. Where the cache contents are grouped. Default empty.
 * @param int    $expire Optional. When to expire the cache contents, in seconds.
 *                       Default 0 (no expiration).
 * @return bool[] Array of return values, grouped by key. Each value is either
 *                true on success, or false on failure.
 * @psalm-param mixed[] $data
 */
function wp_cache_set_multiple( array $data, $group = '', $expire = 0 ) {
	global $wp_object_cache;

	if ( ! defined( 'WP_INSTALLING' ) ) {
		/** @psalm-suppress RedundantCastGivenDocblockType */
		return $wp_object_cache->set_multiple( $data, $group, (int) $expire );
	}

	return $wp_object_cache->delete_multiple( array_keys( $data ), $group );
}

/**
 * Retrieves the cache contents from the cache by key and group.
 *
 * @global WP_Object_Cache $wp_object_cache Object cache global instance.
 *
 * @param int|string $key   The key under which the cache contents are stored.
 * @param string     $group Optional. Where the cache contents are grouped. Default empty.
 * @param bool       $force Optional. Whether to force an update of the local cache
 *                          from the persistent cache. Default false.
 * @param bool       $found Optional. Whether the key was found in the cache (passed by reference).
 *                          Disambiguates a return of false, a storable value. Default null.
 * @return mixed|false The cache contents on success, false on failure to retrieve contents.
 */
function wp_cache_get( $key, $group = '', $force = false, &$found = null ) {
	global $wp_object_cache;

	$value = apply_filters( 'pre_wp_cache_get', false, $key, $group, $force );
	if ( false !== $value ) {
		$found = true;
		return $value;
	}

	return $wp_object_cache->get( $key, $group, $force, $found );
}

/**
 * Retrieves multiple values from the cache in one call.
 *
 * @global WP_Object_Cache $wp_object_cache Object cache global instance.
 *
 * @param array  $keys  Array of keys under which the cache contents are stored.
 * @param string $group Optional. Where the cache contents are grouped. Default empty.
 * @param bool   $force Optional. Whether to force an update of the local cache
 *                      from the persistent cache. Default false.
 * @return array Array of return values, grouped by key. Each value is either
 *               the cache contents on success, or false on failure.
 * @psalm-param (int|string)[] $keys
 * @psalm-return mixed[]
 */
function wp_cache_get_multiple( $keys, $group = '', $force = false ) {
	global $wp_object_cache;

	return $wp_object_cache->get_multiple( $keys, $group, $force );
}

/**
 * Retrieves multiple values from the cache in one call.
 * TODO: Deprecate
 *
 * @global WP_Object_Cache $wp_object_cache Object cache global instance.
 *
 * @param array<string, array<string|int>> $groups  Array of keys, indexed by group. Example: $groups['group-name'] = [ 'key1', 'key2' ]
 *
 * @return array Array of return values, with the full cache key as the index. Each value is either the cache contents on success, or false on failure.
 */
function wp_cache_get_multi( $groups ) {
	global $wp_object_cache;

	return $wp_object_cache->get_multi( $groups );
}

/**
 * Removes the cache contents matching key and group.
 *
 * @global WP_Object_Cache $wp_object_cache Object cache global instance.
 *
 * @param int|string $key   What the contents in the cache are called.
 * @param string     $group Optional. Where the cache contents are grouped. Default empty.
 * @return bool True on successful removal, false on failure.
 */
function wp_cache_delete( $key, $group = '' ) {
	global $wp_object_cache;

	return $wp_object_cache->delete( $key, $group );
}

/**
 * Deletes multiple values from the cache in one call.
 *
 * @global WP_Object_Cache $wp_object_cache Object cache global instance.
 *
 * @param array  $keys  Array of keys under which the cache to deleted.
 * @param string $group Optional. Where the cache contents are grouped. Default empty.
 * @return bool[] Array of return values, grouped by key. Each value is either
 *                true on success, or false if the contents were not deleted.
 * @psalm-param (int|string)[] $keys
 */
function wp_cache_delete_multiple( array $keys, $group = '' ) {
	global $wp_object_cache;

	return $wp_object_cache->delete_multiple( $keys, $group );
}

/**
 * Increments numeric cache item's value.
 *
 * @global WP_Object_Cache $wp_object_cache Object cache global instance.
 *
 * @param int|string $key    The key for the cache contents that should be incremented.
 * @param int        $offset Optional. The amount by which to increment the item's value. Default 1.
 * @param string     $group  Optional. The group the key is in. Default empty.
 * @return int|false The item's new value on success, false on failure.
 */
function wp_cache_incr( $key, $offset = 1, $group = '' ) {
	global $wp_object_cache;

	/** @psalm-suppress RedundantCastGivenDocblockType */
	return $wp_object_cache->incr( $key, (int) $offset, $group );
}

/**
 * Decrements numeric cache item's value.
 *
 * @global WP_Object_Cache $wp_object_cache Object cache global instance.
 *
 * @param int|string $key    The cache key to decrement.
 * @param int        $offset Optional. The amount by which to decrement the item's value. Default 1.
 * @param string     $group  Optional. The group the key is in. Default empty.
 * @return int|false The item's new value on success, false on failure.
 */
function wp_cache_decr( $key, $offset = 1, $group = '' ) {
	global $wp_object_cache;

	/** @psalm-suppress RedundantCastGivenDocblockType */
	return $wp_object_cache->decr( $key, (int) $offset, $group );
}

/**
 * Removes all cache items.
 *
 * @see WP_Object_Cache::flush()
 * @global WP_Object_Cache $wp_object_cache Object cache global instance.
 *
 * @return bool True on success, false on failure.
 */
function wp_cache_flush() {
	global $wp_object_cache;

	return $wp_object_cache->flush();
}

/**
 * Removes all cache items from the in-memory runtime cache.
 *
 * @global WP_Object_Cache $wp_object_cache Object cache global instance.
 *
 * @return bool True on success, false on failure.
 */
function wp_cache_flush_runtime() {
	global $wp_object_cache;

	return $wp_object_cache->flush_runtime();
}

/**
 * Unsupported: Removes all cache items in a group, if the object cache implementation supports it.
 *
 * Before calling this function, always check for group flushing support using the
 * `wp_cache_supports( 'flush_group' )` function.
 *
 * @global WP_Object_Cache $wp_object_cache Object cache global instance.
 *
 * @param string $_group Name of group to remove from cache.
 * @return bool True if group was flushed, false otherwise.
 */
function wp_cache_flush_group( $_group ) {
	global $wp_object_cache;

	return $wp_object_cache->flush_group( $_group );
}

/**
 * Determines whether the object cache implementation supports a particular feature.
 *
 * @param string $feature Name of the feature to check for. Possible values include:
 *                        'add_multiple', 'set_multiple', 'get_multiple', 'delete_multiple',
 *                        'flush_runtime', 'flush_group'.
 * @return bool True if the feature is supported, false otherwise.
 */
function wp_cache_supports( $feature ) {
	switch ( $feature ) {
		case 'add_multiple':
		case 'set_multiple':
		case 'get_multiple':
		case 'delete_multiple':
		case 'flush_runtime':
			return true;

		default:
			return false;
	}
}

/**
 * Closes the cache.
 *
 * @global WP_Object_Cache $wp_object_cache Object cache global instance.
 *
 * @return bool
 */
function wp_cache_close() {
	global $wp_object_cache;

	return $wp_object_cache->close();
}

/**
 * Adds a group or set of groups to the list of global groups.
 *
 * @global WP_Object_Cache $wp_object_cache Object cache global instance.
 *
 * @param string|string[] $groups A group or an array of groups to add.
 * @return void
 */
function wp_cache_add_global_groups( $groups ) {
	global $wp_object_cache;

	$wp_object_cache->add_global_groups( $groups );
}

/**
 * Adds a group or set of groups to the list of non-persistent groups.
 *
 * @global WP_Object_Cache $wp_object_cache Object cache global instance.
 *
 * @param string|string[] $groups A group or an array of groups to add.
 * @return void
 */
function wp_cache_add_non_persistent_groups( $groups ) {
	global $wp_object_cache;

	$wp_object_cache->add_non_persistent_groups( $groups );
}

/**
 * Switches the internal blog ID.
 * This changes the blog id used to create keys in blog specific groups.
 *
 * @global WP_Object_Cache $wp_object_cache Object cache global instance.
 *
 * @param int $blog_id Site ID.
 * @return void
 */
function wp_cache_switch_to_blog( $blog_id ) {
	global $wp_object_cache;

	/** @psalm-suppress RedundantCastGivenDocblockType */
	$wp_object_cache->switch_to_blog( (int) $blog_id );
}