Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.00% covered (warning)
75.00%
75 / 100
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
MemcachedWrapper
75.00% covered (warning)
75.00%
75 / 100
50.00% covered (danger)
50.00%
3 / 6
33.00
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
7
 get
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
6
 set
56.00% covered (warning)
56.00%
14 / 25
0.00% covered (danger)
0.00%
0 / 1
7.13
 delete
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 makeKey
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 makeMemcachedKey
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * WikiLambda memcached access wrapper
4 *
5 * @file
6 * @ingroup Extensions
7 * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt
8 * @license MIT
9 */
10
11namespace MediaWiki\Extension\WikiLambda\Cache;
12
13use InvalidArgumentException;
14use MediaWiki\Config\Config;
15use MediaWiki\Logger\LoggerFactory;
16use MediaWiki\MediaWikiServices;
17use Memcached;
18use Psr\Log\LoggerInterface;
19use Wikimedia\ObjectCache\BagOStuff;
20
21class MemcachedWrapper implements \Wikimedia\LightweightObjectStore\ExpirationAwareness {
22
23    private const TOMBSTONE = '__WIKILAMBDA_TOMBSTONE__';
24
25    /** @var array<string,array{0:Memcached|BagOStuff,1:string}> */
26    private array $services = [];
27
28    private string $broadcastRoute = '';
29
30    private LoggerInterface $logger;
31
32    /**
33     * This is a simple direct wrapper around Memcached that allows us to use multiple configured memcached services,
34     * with different assumptions to those that MediaWiki's BagO'Stuff (and especially WANObjectCache) make. It will
35     * check each service in order for a key, and return the first value it finds, later, but for now it only will go
36     * to the local service. When setting or deleting a key, it will set/delete it via the broadcast route. Deletion
37     * is implemented by setting a tombstone value with a short TTL instead of actually deleting the key, to prevent
38     * cache penetration while allowing the key to eventually be fully removed from the cache.
39     *
40     * @param Config $config
41     */
42    public function __construct( private readonly Config $config ) {
43        // Non-injected items
44        $this->logger = LoggerFactory::getInstance( 'WikiLambdaCache' );
45
46        $configuredCaches = $this->config->get( 'WikiLambdaObjectCaches' );
47
48        foreach ( $configuredCaches as $serviceName => $serviceConfig ) {
49            $prefix = $serviceConfig['prefix'] ?? "/$serviceName/";
50            $this->logger->debug(
51                'Constructing a cache {serviceName} with prefix {prefix}',
52                [ 'serviceName' => $serviceName, 'prefix' => $prefix ]
53            );
54
55            $memcached = new Memcached();
56            // Parse the server address (host:port or UDS path) using the same logic as
57            // MemcachedPeclBagOStuff, to ensure config compatibility.
58            if ( preg_match( '/^\[(.+)\]:(\d+)$/', $serviceConfig['server'], $m ) ) {
59                // (ipv6, port)
60                $host = $m[1];
61                $port = (int)$m[2];
62            } elseif ( preg_match( '/^([^:]+):(\d+)$/', $serviceConfig['server'], $m ) ) {
63                // (ipv4 or domain name, port)
64                $host = $m[1];
65                $port = (int)$m[2];
66            } else {
67                // (socket path, no port)
68                $host = $serviceConfig['server'];
69                $port = false;
70            }
71            $memcached->addServer( $host, $port );
72
73            $this->services[$serviceName] = [ $memcached, $prefix ];
74        }
75
76        if ( count( $this->services ) === 0 ) {
77            $this->logger->info( 'No memcached services configured, falling back to MW\'s main BagOStuff' );
78
79            $this->services['main'] = [ MediaWikiServices::getInstance()->getMainObjectStash(), 'wikilambda:' ];
80            $this->broadcastRoute = 'wikilambda:';
81            return;
82        }
83
84        $configuredBroadcast = $this->config->get( 'WikiLambdaObjectCacheBroadcast' );
85        if ( $configuredBroadcast !== null ) {
86            $this->logger->debug( 'Setting broadcast cache route as {bcast}', [ 'bcast' => $configuredBroadcast ] );
87            $this->broadcastRoute = $configuredBroadcast;
88        }
89
90        if ( $this->broadcastRoute === '' ) {
91            $this->logger->warning( 'No broadcast cache route configured, falling back to first known cache' );
92            $this->broadcastRoute = array_key_first( $this->services );
93        }
94
95        $this->logger->debug( 'Finished constructing {count} caches', [ 'count' => count( $this->services ) ] );
96    }
97
98    /**
99     * Checks the local memcached service and returns the value for the given key.
100     *
101     * @param string $key The key to retrieve
102     * @return mixed The value associated with the key from the DC-local memcached service, or false if the key
103     *   is not found.
104     */
105    public function get( string $key ): mixed {
106        $this->logger->debug( __METHOD__ . ': cache check for {key}', [ 'key' => $key ] );
107
108        // Get only our DC local service.
109        $localServiceName = array_key_first( $this->services );
110        [ $localService, $localPrefix ] = $this->services[ $localServiceName ];
111
112        // TODO: Consider checking the remote service(s) too.
113
114        $targetKey = $localPrefix . $key;
115
116        $value = $localService->get( $targetKey );
117        if (
118            ( $localService instanceof Memcached && $localService->getResultCode() === Memcached::RES_SUCCESS ) ||
119            ( $localService instanceof BagOStuff && $value !== false )
120        ) {
121            if ( $value === self::TOMBSTONE ) {
122                $this->logger->debug(
123                    __METHOD__ . ': cache tombstone found for prefixed {key} from {service}, setting as cache miss',
124                    [ 'key' => $targetKey, 'service' => $localServiceName ]
125                );
126                return false;
127            }
128            $this->logger->debug(
129                __METHOD__ . ': cache hit for prefixed {key} from {service}',
130                [ 'key' => $targetKey, 'service' => $localServiceName ]
131            );
132            return $value;
133        }
134        $this->logger->debug(
135            __METHOD__ . ': cache miss for prefixed {key} from {service}',
136            [ 'key' => $targetKey, 'service' => $localServiceName ]
137        );
138        return false;
139    }
140
141    /**
142     * Attempt to set the given key via the broadcast route. We use mcrouter to convey this across service(s).
143     *
144     * @param string $key The key to set
145     * @param mixed $value The value to set
146     * @param int $ttl Time to live in seconds (default 60*60*24*30 seconds = 30 days)
147     * @return bool Whether the set operation succeeded
148     */
149    public function set( string $key, mixed $value, int $ttl = self::TTL_MONTH ): bool {
150        if ( $this->broadcastRoute === '' ) {
151            $this->logger->warning( __METHOD__ . ': no broadcast cache configured!' );
152            return false;
153        }
154
155        $localServiceName = array_keys( $this->services )[0];
156
157        // Note: We ignore the local prefix, as we're using the broadcast route instead.
158        $localService = $this->services[$localServiceName][0];
159
160        $this->logger->debug(
161            __METHOD__ . ': setting {key} on local server {localService} with broadcast cache route {route}',
162            [ 'key' => $key, 'localService' => $localServiceName, 'route' => $this->broadcastRoute ]
163        );
164
165        $success = $localService->set( $this->broadcastRoute . $key, $value, $ttl );
166
167        if ( !$success ) {
168            $this->logger->warning(
169                __METHOD__ . ': failed to set broadcast prefixed {key} on {service} with error {code}: {err}',
170                [
171                    'key' => $this->broadcastRoute . $key,
172                    'service' => $localServiceName,
173                    'code' => ( $localService instanceof Memcached ? $localService->getResultCode() : '?' ),
174                    'err' => ( $localService instanceof Memcached ? $localService->getResultMessage() : '?' ),
175                ]
176            );
177        } else {
178            $this->logger->debug(
179                __METHOD__ . ': successfully set broadcast prefixed {key} on {service}',
180                [ 'key' => $this->broadcastRoute . $key, 'service' => $localServiceName ]
181            );
182
183        }
184
185        return $success;
186    }
187
188    /**
189     * Attempt to delete the given key via the broadcast route (setting a tombstone value)
190     *
191     * @param string $key The key to delete
192     * @return bool Whether the delete operation succeeded on the broadcast cache.
193     */
194    public function delete( string $key ): bool {
195        $this->logger->debug( __METHOD__ . ': deleting {key} by setting a tombstone value', [ 'key' => $key ] );
196        return $this->set( $key, self::TOMBSTONE, self::TTL_MINUTE );
197    }
198
199    /**
200     * Utility method to create a cache key by concatenating parts with a colon.
201     * This is used to ensure consistent cache key formatting across the codebase.
202     *
203     * If the cache service is BagOStuff, fallback to its makeKey method to ensure
204     * that the constraints for each implementation of BagOStuff are followed.
205     *
206     * If the cache service is Memcached, implement its own logic for correct ASCII
207     * encoding and limited key size (250 characters max)
208     *
209     * @see MemcachedBagOStuff::makeKeyInternal
210     * @param string $prefix A prefix to identify the type of cache entry (e.g. 'functioncall')
211     * @param string ...$parts The parts to concatenate into a cache key
212     * @throws InvalidArgumentException If no parts are provided
213     * @return string The generated cache key
214     */
215    public function makeKey( string $prefix, string ...$parts ): string {
216        // Get only our DC local service.
217        $localServiceName = array_key_first( $this->services );
218        [ $localService, $localPrefix ] = $this->services[ $localServiceName ];
219
220        // Fallback to their own makeKey logic when the service is BagOStuff
221        // E.g. SqlBagOStuff keys have a maximum length of 250 characters
222        if ( $localService instanceof BagOStuff ) {
223            return $localService->makeKey( $prefix, ...$parts );
224        }
225
226        return $this->makeMemcachedKey( $prefix, ...$parts );
227    }
228
229    /**
230     * Utility method to create a cache key for a Memcached backed cache service.
231     * Concatenates all string parts and, if the result exceeds 205 characters
232     * (250 max, minus 45 for prefixes) hashes the parts and prepends the prefix.
233     *
234     * @param string $prefix A prefix to identify the type of cache entry (e.g. 'functioncall')
235     * @param string ...$parts The parts to concatenate into a cache key
236     * @return string The generated cache key
237     */
238    protected function makeMemcachedKey( string $prefix, string ...$parts ): string {
239        // Keep 45 characters for prefixes (e.g. 'wikilambda:WikiLambdaClientFunctionCall')
240        $maxLength = 205;
241
242        // Add the prefix as the last step, just in case we need to hash the parts
243        $key = '';
244        foreach ( $parts as $part ) {
245            $part = strtr( $part ?? '', ' ', '_' );
246
247            // Make sure %, #, and non-ASCII chars are escaped
248            $part = preg_replace_callback(
249                '/[^\x21-\x22\x24\x26-\x39\x3b-\x7e]+/',
250                static fn ( $m ) => rawurlencode( $m[0] ),
251                $part
252            );
253
254            $key .= ':' . $part;
255        }
256
257        // If the joint and encoded parts is larger than maxLength, hash it
258        if ( strlen( $key ) > $maxLength ) {
259            $key = '#' . hash( 'sha256', $key );
260        }
261
262        // Add the prefix; the result should not be longer than 250 characters
263        return $prefix . ':' . $key;
264    }
265}