Merge "Fix use of GenderCache in ApiPageSet::processTitlesArray"
[lhc/web/wiklou.git] / includes / config / EtcdConfig.php
1 <?php
2 /**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21 use Psr\Log\LoggerAwareInterface;
22 use Psr\Log\LoggerInterface;
23 use Wikimedia\ObjectFactory;
24 use Wikimedia\WaitConditionLoop;
25
26 /**
27 * Interface for configuration instances
28 *
29 * @since 1.29
30 */
31 class EtcdConfig implements Config, LoggerAwareInterface {
32 /** @var MultiHttpClient */
33 private $http;
34 /** @var BagOStuff */
35 private $srvCache;
36 /** @var array */
37 private $procCache;
38 /** @var LoggerInterface */
39 private $logger;
40
41 /** @var string */
42 private $host;
43 /** @var string */
44 private $protocol;
45 /** @var string */
46 private $directory;
47 /** @var string */
48 private $encoding;
49 /** @var int */
50 private $baseCacheTTL;
51 /** @var int */
52 private $skewCacheTTL;
53 /** @var int */
54 private $timeout;
55
56 /**
57 * @param array $params Parameter map:
58 * - host: the host address and port
59 * - protocol: either http or https
60 * - directory: the etc "directory" were MediaWiki specific variables are located
61 * - encoding: one of ("JSON", "YAML"). Defaults to JSON. [optional]
62 * - cache: BagOStuff instance or ObjectFactory spec thereof for a server cache.
63 * The cache will also be used as a fallback if etcd is down. [optional]
64 * - cacheTTL: logical cache TTL in seconds [optional]
65 * - skewTTL: maximum seconds to randomly lower the assigned TTL on cache save [optional]
66 * - timeout: seconds to wait for etcd before throwing an error [optional]
67 */
68 public function __construct( array $params ) {
69 $params += [
70 'protocol' => 'http',
71 'encoding' => 'JSON',
72 'cacheTTL' => 10,
73 'skewTTL' => 1,
74 'timeout' => 2
75 ];
76
77 $this->host = $params['host'];
78 $this->protocol = $params['protocol'];
79 $this->directory = trim( $params['directory'], '/' );
80 $this->encoding = $params['encoding'];
81 $this->skewCacheTTL = $params['skewTTL'];
82 $this->baseCacheTTL = max( $params['cacheTTL'] - $this->skewCacheTTL, 0 );
83 $this->timeout = $params['timeout'];
84
85 if ( !isset( $params['cache'] ) ) {
86 $this->srvCache = new HashBagOStuff();
87 } elseif ( $params['cache'] instanceof BagOStuff ) {
88 $this->srvCache = $params['cache'];
89 } else {
90 $this->srvCache = ObjectFactory::getObjectFromSpec( $params['cache'] );
91 }
92
93 $this->logger = new Psr\Log\NullLogger();
94 $this->http = new MultiHttpClient( [
95 'connTimeout' => $this->timeout,
96 'reqTimeout' => $this->timeout,
97 'logger' => $this->logger
98 ] );
99 }
100
101 public function setLogger( LoggerInterface $logger ) {
102 $this->logger = $logger;
103 $this->http->setLogger( $logger );
104 }
105
106 public function has( $name ) {
107 $this->load();
108
109 return array_key_exists( $name, $this->procCache['config'] );
110 }
111
112 public function get( $name ) {
113 $this->load();
114
115 if ( !array_key_exists( $name, $this->procCache['config'] ) ) {
116 throw new ConfigException( "No entry found for '$name'." );
117 }
118
119 return $this->procCache['config'][$name];
120 }
121
122 public function getModifiedIndex() {
123 $this->load();
124 return $this->procCache['modifiedIndex'];
125 }
126
127 /**
128 * @throws ConfigException
129 */
130 private function load() {
131 if ( $this->procCache !== null ) {
132 return; // already loaded
133 }
134
135 $now = microtime( true );
136 $key = $this->srvCache->makeGlobalKey(
137 __CLASS__,
138 $this->host,
139 $this->directory
140 );
141
142 // Get the cached value or block until it is regenerated (by this or another thread)...
143 $data = null; // latest config info
144 $error = null; // last error message
145 $loop = new WaitConditionLoop(
146 function () use ( $key, $now, &$data, &$error ) {
147 // Check if the values are in cache yet...
148 $data = $this->srvCache->get( $key );
149 if ( is_array( $data ) && $data['expires'] > $now ) {
150 $this->logger->debug( "Found up-to-date etcd configuration cache." );
151
152 return WaitConditionLoop::CONDITION_REACHED;
153 }
154
155 // Cache is either empty or stale;
156 // refresh the cache from etcd, using a mutex to reduce stampedes...
157 if ( $this->srvCache->lock( $key, 0, $this->baseCacheTTL ) ) {
158 try {
159 $etcdResponse = $this->fetchAllFromEtcd();
160 $error = $etcdResponse['error'];
161 if ( is_array( $etcdResponse['config'] ) ) {
162 // Avoid having all servers expire cache keys at the same time
163 $expiry = microtime( true ) + $this->baseCacheTTL;
164 // @phan-suppress-next-line PhanTypeMismatchArgumentInternal
165 $expiry += mt_rand( 0, 1e6 ) / 1e6 * $this->skewCacheTTL;
166 $data = [
167 'config' => $etcdResponse['config'],
168 'expires' => $expiry,
169 'modifiedIndex' => $etcdResponse['modifiedIndex']
170 ];
171 $this->srvCache->set( $key, $data, BagOStuff::TTL_INDEFINITE );
172
173 $this->logger->info( "Refreshed stale etcd configuration cache." );
174
175 return WaitConditionLoop::CONDITION_REACHED;
176 } else {
177 $this->logger->error( "Failed to fetch configuration: $error" );
178 if ( !$etcdResponse['retry'] ) {
179 // Fail fast since the error is likely to keep happening
180 return WaitConditionLoop::CONDITION_FAILED;
181 }
182 }
183 } finally {
184 $this->srvCache->unlock( $key ); // release mutex
185 }
186 }
187
188 if ( is_array( $data ) ) {
189 $this->logger->info( "Using stale etcd configuration cache." );
190
191 return WaitConditionLoop::CONDITION_REACHED;
192 }
193
194 return WaitConditionLoop::CONDITION_CONTINUE;
195 },
196 $this->timeout
197 );
198
199 if ( $loop->invoke() !== WaitConditionLoop::CONDITION_REACHED ) {
200 // No cached value exists and etcd query failed; throw an error
201 throw new ConfigException( "Failed to load configuration from etcd: $error" );
202 }
203
204 $this->procCache = $data;
205 }
206
207 /**
208 * @return array (containing the keys config, error, retry, modifiedIndex)
209 */
210 public function fetchAllFromEtcd() {
211 // TODO: inject DnsSrvDiscoverer in order to be able to test this method
212 $dsd = new DnsSrvDiscoverer( $this->host );
213 $servers = $dsd->getServers();
214 if ( !$servers ) {
215 return $this->fetchAllFromEtcdServer( $this->host );
216 }
217
218 do {
219 // Pick a random etcd server from dns
220 $server = $dsd->pickServer( $servers );
221 $host = IP::combineHostAndPort( $server['target'], $server['port'] );
222 // Try to load the config from this particular server
223 $response = $this->fetchAllFromEtcdServer( $host );
224 if ( is_array( $response['config'] ) || $response['retry'] ) {
225 break;
226 }
227
228 // Avoid the server next time if that failed
229 $servers = $dsd->removeServer( $server, $servers );
230 } while ( $servers );
231
232 return $response;
233 }
234
235 /**
236 * @param string $address Host and port
237 * @return array (containing the keys config, error, retry, modifiedIndex)
238 */
239 protected function fetchAllFromEtcdServer( $address ) {
240 // Retrieve all the values under the MediaWiki config directory
241 list( $rcode, $rdesc, /* $rhdrs */, $rbody, $rerr ) = $this->http->run( [
242 'method' => 'GET',
243 'url' => "{$this->protocol}://{$address}/v2/keys/{$this->directory}/?recursive=true",
244 'headers' => [ 'content-type' => 'application/json' ]
245 ] );
246
247 $response = [ 'config' => null, 'error' => null, 'retry' => false, 'modifiedIndex' => 0 ];
248
249 static $terminalCodes = [ 404 => true ];
250 if ( $rcode < 200 || $rcode > 399 ) {
251 $response['error'] = strlen( $rerr ) ? $rerr : "HTTP $rcode ($rdesc)";
252 $response['retry'] = empty( $terminalCodes[$rcode] );
253 return $response;
254 }
255
256 try {
257 $parsedResponse = $this->parseResponse( $rbody );
258 } catch ( EtcdConfigParseError $e ) {
259 $parsedResponse = [ 'error' => $e->getMessage() ];
260 }
261 return array_merge( $response, $parsedResponse );
262 }
263
264 /**
265 * Parse a response body, throwing EtcdConfigParseError if there is a validation error
266 *
267 * @param string $rbody
268 * @return array
269 */
270 protected function parseResponse( $rbody ) {
271 $info = json_decode( $rbody, true );
272 if ( $info === null ) {
273 throw new EtcdConfigParseError( "Error unserializing JSON response." );
274 }
275 if ( !isset( $info['node'] ) || !is_array( $info['node'] ) ) {
276 throw new EtcdConfigParseError(
277 "Unexpected JSON response: Missing or invalid node at top level." );
278 }
279 $config = [];
280 $lastModifiedIndex = $this->parseDirectory( '', $info['node'], $config );
281 return [ 'modifiedIndex' => $lastModifiedIndex, 'config' => $config ];
282 }
283
284 /**
285 * Recursively parse a directory node and populate the array passed by
286 * reference, throwing EtcdConfigParseError if there is a validation error
287 *
288 * @param string $dirName The relative directory name
289 * @param array $dirNode The decoded directory node
290 * @param array &$config The output array
291 * @return int lastModifiedIndex The maximum last modified index across all keys in the directory
292 */
293 protected function parseDirectory( $dirName, $dirNode, &$config ) {
294 $lastModifiedIndex = 0;
295 if ( !isset( $dirNode['nodes'] ) ) {
296 throw new EtcdConfigParseError(
297 "Unexpected JSON response in dir '$dirName'; missing 'nodes' list." );
298 }
299 if ( !is_array( $dirNode['nodes'] ) ) {
300 throw new EtcdConfigParseError(
301 "Unexpected JSON response in dir '$dirName'; 'nodes' is not an array." );
302 }
303
304 foreach ( $dirNode['nodes'] as $node ) {
305 $baseName = basename( $node['key'] );
306 $fullName = $dirName === '' ? $baseName : "$dirName/$baseName";
307 if ( !empty( $node['dir'] ) ) {
308 $lastModifiedIndex = max(
309 $this->parseDirectory( $fullName, $node, $config ),
310 $lastModifiedIndex );
311 } else {
312 $value = $this->unserialize( $node['value'] );
313 if ( !is_array( $value ) || !array_key_exists( 'val', $value ) ) {
314 throw new EtcdConfigParseError( "Failed to parse value for '$fullName'." );
315 }
316 $lastModifiedIndex = max( $node['modifiedIndex'], $lastModifiedIndex );
317 $config[$fullName] = $value['val'];
318 }
319 }
320 return $lastModifiedIndex;
321 }
322
323 /**
324 * @param string $string
325 * @return mixed
326 */
327 private function unserialize( $string ) {
328 if ( $this->encoding === 'YAML' ) {
329 return yaml_parse( $string );
330 } else {
331 return json_decode( $string, true );
332 }
333 }
334 }