MediaWiki master
EtcdConfig.php
Go to the documentation of this file.
1<?php
7namespace MediaWiki\Config;
8
11use Wikimedia\IPUtils;
14use Wikimedia\ObjectFactory\ObjectFactory;
15use Wikimedia\WaitConditionLoop;
16use function array_key_exists;
17
23class EtcdConfig implements Config {
24 private readonly MultiHttpClient $http;
25 private readonly BagOStuff $srvCache;
27 private $procCache;
29 private $dsd;
30
32 private $host;
34 private $port;
36 private $protocol;
38 private $directory;
40 private $baseCacheTTL;
42 private $skewCacheTTL;
44 private $timeout;
45
59 public function __construct( array $params ) {
60 $params += [
61 'service' => 'etcd',
62 'port' => null,
63 'protocol' => 'http',
64 'cacheTTL' => 10,
65 'skewTTL' => 1,
66 'timeout' => 2
67 ];
68
69 $service = $params['service'];
70 $this->host = $params['host'];
71 $this->port = $params['port'];
72 $this->protocol = $params['protocol'];
73 $this->directory = trim( $params['directory'], '/' );
74 $this->skewCacheTTL = $params['skewTTL'];
75 $this->baseCacheTTL = max( $params['cacheTTL'] - $this->skewCacheTTL, 0 );
76 $this->timeout = $params['timeout'];
77
78 // For backwards compatibility, check the host for an embedded port
79 $hostAndPort = IPUtils::splitHostAndPort( $this->host );
80
81 if ( $hostAndPort ) {
82 $this->host = $hostAndPort[0];
83
84 if ( $hostAndPort[1] ) {
85 $this->port = $hostAndPort[1];
86 }
87 }
88
89 // Also for backwards compatibility, check for a host in the format of
90 // an SRV record and use the service specified therein
91 if ( preg_match( '/^_([^\.]+)\._tcp\.(.+)$/', $this->host, $m ) ) {
92 $service = $m[1];
93 $this->host = $m[2];
94 }
95
96 if ( !isset( $params['cache'] ) ) {
97 $this->srvCache = new HashBagOStuff();
98 } elseif ( $params['cache'] instanceof BagOStuff ) {
99 $this->srvCache = $params['cache'];
100 } else {
101 $this->srvCache = ObjectFactory::getObjectFromSpec( $params['cache'] );
102 }
103
104 $this->http = new MultiHttpClient( [
105 'connTimeout' => $this->timeout,
106 'reqTimeout' => $this->timeout,
107 ] );
108 $this->dsd = new DnsSrvDiscoverer( $service, 'tcp', $this->host );
109 }
110
112 public function has( $name ) {
113 $this->load();
114
115 return array_key_exists( $name, $this->procCache['config'] );
116 }
117
119 public function get( $name ) {
120 $this->load();
121
122 if ( !array_key_exists( $name, $this->procCache['config'] ) ) {
123 throw new ConfigException( "No entry found for '$name'." );
124 }
125
126 return $this->procCache['config'][$name];
127 }
128
129 public function getModifiedIndex(): int {
130 $this->load();
131 return (int)$this->procCache['modifiedIndex'];
132 }
133
137 private function load() {
138 if ( $this->procCache !== null ) {
139 return; // already loaded
140 }
141
142 $now = microtime( true );
143 $key = $this->srvCache->makeGlobalKey(
144 __CLASS__,
145 $this->host,
146 $this->directory
147 );
148
149 // Get the cached value or block until it is regenerated (by this or another thread)...
150 $data = null; // latest config info
151 $error = null; // last error message
152 $loop = new WaitConditionLoop(
153 function () use ( $key, $now, &$data, &$error ) {
154 // Check if the values are in cache yet...
155 $data = $this->srvCache->get( $key );
156 if ( is_array( $data ) && $data['expires'] > $now ) {
157 return WaitConditionLoop::CONDITION_REACHED;
158 }
159
160 // Cache is either empty or stale;
161 // refresh the cache from etcd, using a mutex to reduce stampedes...
162 if ( $this->srvCache->lock( $key, 0, $this->baseCacheTTL ) ) {
163 try {
164 $etcdResponse = $this->fetchAllFromEtcd();
165 $error = $etcdResponse['error'];
166 if ( is_array( $etcdResponse['config'] ) ) {
167 // Avoid having all servers expire cache keys at the same time
168 $expiry = microtime( true ) + $this->baseCacheTTL;
169 // @phan-suppress-next-line PhanTypeMismatchArgumentInternal
170 $expiry += mt_rand( 0, 1e6 ) / 1e6 * $this->skewCacheTTL;
171 $data = [
172 'config' => $etcdResponse['config'],
173 'expires' => $expiry,
174 'modifiedIndex' => $etcdResponse['modifiedIndex']
175 ];
176 $this->srvCache->set( $key, $data, BagOStuff::TTL_INDEFINITE );
177
178 return WaitConditionLoop::CONDITION_REACHED;
179 } else {
180 trigger_error( "EtcdConfig failed to fetch data: $error", E_USER_WARNING );
181 if ( !$etcdResponse['retry'] && !is_array( $data ) ) {
182 // Fail fast since the error is likely to keep happening
183 return WaitConditionLoop::CONDITION_FAILED;
184 }
185 }
186 } finally {
187 $this->srvCache->unlock( $key ); // release mutex
188 }
189 } else {
190 $error = 'lost lock';
191 }
192
193 if ( is_array( $data ) ) {
194 trigger_error( "EtcdConfig using stale data: $error", E_USER_NOTICE );
195
196 return WaitConditionLoop::CONDITION_REACHED;
197 }
198
199 return WaitConditionLoop::CONDITION_CONTINUE;
200 },
201 $this->timeout
202 );
203
204 if ( $loop->invoke() !== WaitConditionLoop::CONDITION_REACHED ) {
205 // No cached value exists and etcd query failed; throw an error
206 // @phan-suppress-next-line PhanTypeSuspiciousStringExpression WaitConditionLoop throws or error set
207 throw new ConfigException( "Failed to load configuration from etcd: $error" );
208 }
209
210 // @phan-suppress-next-line PhanTypeMismatchProperty WaitConditionLoop throws ore data set
211 $this->procCache = $data;
212 }
213
217 public function fetchAllFromEtcd() {
218 $servers = $this->dsd->getServers() ?: [ [ $this->host, $this->port ] ];
219
220 foreach ( $servers as [ $host, $port ] ) {
221 // Try to load the config from this particular server
222 $response = $this->fetchAllFromEtcdServer( $host, $port );
223 if ( is_array( $response['config'] ) || $response['retry'] ) {
224 break;
225 }
226 }
227
228 return $response;
229 }
230
236 protected function fetchAllFromEtcdServer( string $address, ?int $port = null ) {
237 $host = $address;
238
239 if ( $port !== null ) {
240 $host = IPUtils::combineHostAndPort( $address, $port );
241 }
242
243 // Retrieve all the values under the MediaWiki config directory
244 [ $rcode, $rdesc, /* $rhdrs */, $rbody, $rerr ] = $this->http->run( [
245 'method' => 'GET',
246 'url' => "{$this->protocol}://{$host}/v2/keys/{$this->directory}/?recursive=true",
247 'headers' => [
248 'content-type' => 'application/json',
249 ]
250 ] );
251
252 $response = [ 'config' => null, 'error' => null, 'retry' => false, 'modifiedIndex' => 0 ];
253
254 static $terminalCodes = [ 404 => true ];
255 if ( $rcode < 200 || $rcode > 399 ) {
256 $response['error'] = strlen( $rerr ?? '' ) ? $rerr : "HTTP $rcode ($rdesc)";
257 $response['retry'] = empty( $terminalCodes[$rcode] );
258 return $response;
259 }
260
261 try {
262 $parsedResponse = $this->parseResponse( $rbody );
263 } catch ( EtcdConfigParseError $e ) {
264 $parsedResponse = [ 'error' => $e->getMessage() ];
265 }
266 return array_merge( $response, $parsedResponse );
267 }
268
276 protected function parseResponse( $rbody ) {
277 $info = json_decode( $rbody, true );
278 if ( $info === null ) {
279 throw new EtcdConfigParseError( "Error unserializing JSON response." );
280 }
281 if ( !isset( $info['node'] ) || !is_array( $info['node'] ) ) {
282 throw new EtcdConfigParseError(
283 "Unexpected JSON response: Missing or invalid node at top level." );
284 }
285 $config = [];
286 $lastModifiedIndex = $this->parseDirectory( '', $info['node'], $config );
287 return [ 'modifiedIndex' => $lastModifiedIndex, 'config' => $config ];
288 }
289
300 protected function parseDirectory( $dirName, $dirNode, &$config ) {
301 $lastModifiedIndex = 0;
302 if ( !isset( $dirNode['nodes'] ) ) {
303 throw new EtcdConfigParseError(
304 "Unexpected JSON response in dir '$dirName'; missing 'nodes' list." );
305 }
306 if ( !is_array( $dirNode['nodes'] ) ) {
307 throw new EtcdConfigParseError(
308 "Unexpected JSON response in dir '$dirName'; 'nodes' is not an array." );
309 }
310
311 foreach ( $dirNode['nodes'] as $node ) {
312 '@phan-var array $node';
313 $baseName = basename( $node['key'] );
314 $fullName = $dirName === '' ? $baseName : "$dirName/$baseName";
315 if ( !empty( $node['dir'] ) ) {
316 $lastModifiedIndex = max(
317 $this->parseDirectory( $fullName, $node, $config ),
318 $lastModifiedIndex );
319 } else {
320 $value = $this->unserialize( $node['value'] );
321 if ( !is_array( $value ) || !array_key_exists( 'val', $value ) ) {
322 throw new EtcdConfigParseError( "Failed to parse value for '$fullName'." );
323 }
324 $lastModifiedIndex = max( $node['modifiedIndex'], $lastModifiedIndex );
325 $config[$fullName] = $value['val'];
326 }
327 }
328 return $lastModifiedIndex;
329 }
330
335 private function unserialize( $string ) {
336 return json_decode( $string, true );
337 }
338}
339
341class_alias( EtcdConfig::class, 'EtcdConfig' );
Exceptions for config failures.
Interface for configuration instances.
parseResponse( $rbody)
Parse a response body, throwing EtcdConfigParseError if there is a validation error.
parseDirectory( $dirName, $dirNode, &$config)
Recursively parse a directory node and populate the array passed by reference, throwing EtcdConfigPar...
fetchAllFromEtcdServer(string $address, ?int $port=null)
has( $name)
Check whether a configuration option is set for the given name.bool 1.24
Class to handle multiple HTTP requests.
Abstract class for any ephemeral data store.
Definition BagOStuff.php:73
Store data in a memory for the current request/process only.
Interface for configuration instances.
Definition Config.php:18