MediaWiki REL1_37
EtcdConfig.php
Go to the documentation of this file.
1<?php
21use Psr\Log\LoggerAwareInterface;
22use Psr\Log\LoggerInterface;
23use Wikimedia\IPUtils;
24use Wikimedia\ObjectFactory;
25use Wikimedia\WaitConditionLoop;
26
32class EtcdConfig implements Config, LoggerAwareInterface {
34 private $http;
36 private $srvCache;
38 private $procCache;
40 private $logger;
41
43 private $host;
45 private $protocol;
47 private $directory;
49 private $encoding;
55 private $timeout;
56
69 public function __construct( array $params ) {
70 $params += [
71 'protocol' => 'http',
72 'encoding' => 'JSON',
73 'cacheTTL' => 10,
74 'skewTTL' => 1,
75 'timeout' => 2
76 ];
77
78 $this->host = $params['host'];
79 $this->protocol = $params['protocol'];
80 $this->directory = trim( $params['directory'], '/' );
81 $this->encoding = $params['encoding'];
82 $this->skewCacheTTL = $params['skewTTL'];
83 $this->baseCacheTTL = max( $params['cacheTTL'] - $this->skewCacheTTL, 0 );
84 $this->timeout = $params['timeout'];
85
86 if ( !isset( $params['cache'] ) ) {
87 $this->srvCache = new HashBagOStuff();
88 } elseif ( $params['cache'] instanceof BagOStuff ) {
89 $this->srvCache = $params['cache'];
90 } else {
91 $this->srvCache = ObjectFactory::getObjectFromSpec( $params['cache'] );
92 }
93
94 $this->logger = new Psr\Log\NullLogger();
95 $this->http = new MultiHttpClient( [
96 'connTimeout' => $this->timeout,
97 'reqTimeout' => $this->timeout,
98 'logger' => $this->logger
99 ] );
100 }
101
102 public function setLogger( LoggerInterface $logger ) {
103 $this->logger = $logger;
104 $this->http->setLogger( $logger );
105 }
106
107 public function has( $name ) {
108 $this->load();
109
110 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable procCache is set after load()
111 return array_key_exists( $name, $this->procCache['config'] );
112 }
113
114 public function get( $name ) {
115 $this->load();
116
117 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable procCache is set after load()
118 if ( !array_key_exists( $name, $this->procCache['config'] ) ) {
119 throw new ConfigException( "No entry found for '$name'." );
120 }
121
122 return $this->procCache['config'][$name];
123 }
124
125 public function getModifiedIndex() {
126 $this->load();
127 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable procCache is set after load()
128 return $this->procCache['modifiedIndex'];
129 }
130
134 private function load() {
135 if ( $this->procCache !== null ) {
136 return; // already loaded
137 }
138
139 $now = microtime( true );
140 $key = $this->srvCache->makeGlobalKey(
141 __CLASS__,
142 $this->host,
143 $this->directory
144 );
145
146 // Get the cached value or block until it is regenerated (by this or another thread)...
147 $data = null; // latest config info
148 $error = null; // last error message
149 $loop = new WaitConditionLoop(
150 function () use ( $key, $now, &$data, &$error ) {
151 // Check if the values are in cache yet...
152 $data = $this->srvCache->get( $key );
153 if ( is_array( $data ) && $data['expires'] > $now ) {
154 $this->logger->debug( "Found up-to-date etcd configuration cache." );
155
156 return WaitConditionLoop::CONDITION_REACHED;
157 }
158
159 // Cache is either empty or stale;
160 // refresh the cache from etcd, using a mutex to reduce stampedes...
161 if ( $this->srvCache->lock( $key, 0, $this->baseCacheTTL ) ) {
162 try {
163 $etcdResponse = $this->fetchAllFromEtcd();
164 $error = $etcdResponse['error'];
165 if ( is_array( $etcdResponse['config'] ) ) {
166 // Avoid having all servers expire cache keys at the same time
167 $expiry = microtime( true ) + $this->baseCacheTTL;
168 // @phan-suppress-next-line PhanTypeMismatchArgumentInternal
169 $expiry += mt_rand( 0, 1e6 ) / 1e6 * $this->skewCacheTTL;
170 $data = [
171 'config' => $etcdResponse['config'],
172 'expires' => $expiry,
173 'modifiedIndex' => $etcdResponse['modifiedIndex']
174 ];
175 $this->srvCache->set( $key, $data, BagOStuff::TTL_INDEFINITE );
176
177 $this->logger->info( "Refreshed stale etcd configuration cache." );
178
179 return WaitConditionLoop::CONDITION_REACHED;
180 } else {
181 $this->logger->error( "Failed to fetch configuration: $error" );
182 if ( !$etcdResponse['retry'] ) {
183 // Fail fast since the error is likely to keep happening
184 return WaitConditionLoop::CONDITION_FAILED;
185 }
186 }
187 } finally {
188 $this->srvCache->unlock( $key ); // release mutex
189 }
190 }
191
192 if ( is_array( $data ) ) {
193 $this->logger->info( "Using stale etcd configuration cache." );
194
195 return WaitConditionLoop::CONDITION_REACHED;
196 }
197
198 return WaitConditionLoop::CONDITION_CONTINUE;
199 },
201 );
202
203 if ( $loop->invoke() !== WaitConditionLoop::CONDITION_REACHED ) {
204 // No cached value exists and etcd query failed; throw an error
205 throw new ConfigException( "Failed to load configuration from etcd: $error" );
206 }
207
208 $this->procCache = $data;
209 }
210
214 public function fetchAllFromEtcd() {
215 // TODO: inject DnsSrvDiscoverer in order to be able to test this method
216 $dsd = new DnsSrvDiscoverer( $this->host );
217 $servers = $dsd->getServers();
218 if ( !$servers ) {
219 return $this->fetchAllFromEtcdServer( $this->host );
220 }
221
222 do {
223 // Pick a random etcd server from dns
224 $server = $dsd->pickServer( $servers );
225 $host = IPUtils::combineHostAndPort( $server['target'], $server['port'] );
226 // Try to load the config from this particular server
227 $response = $this->fetchAllFromEtcdServer( $host );
228 if ( is_array( $response['config'] ) || $response['retry'] ) {
229 break;
230 }
231
232 // Avoid the server next time if that failed
233 $servers = $dsd->removeServer( $server, $servers );
234 } while ( $servers );
235
236 return $response;
237 }
238
243 protected function fetchAllFromEtcdServer( $address ) {
244 // Retrieve all the values under the MediaWiki config directory
245 list( $rcode, $rdesc, /* $rhdrs */, $rbody, $rerr ) = $this->http->run( [
246 'method' => 'GET',
247 'url' => "{$this->protocol}://{$address}/v2/keys/{$this->directory}/?recursive=true",
248 'headers' => [ 'content-type' => 'application/json' ]
249 ] );
250
251 $response = [ 'config' => null, 'error' => null, 'retry' => false, 'modifiedIndex' => 0 ];
252
253 static $terminalCodes = [ 404 => true ];
254 if ( $rcode < 200 || $rcode > 399 ) {
255 $response['error'] = strlen( $rerr ?? '' ) ? $rerr : "HTTP $rcode ($rdesc)";
256 $response['retry'] = empty( $terminalCodes[$rcode] );
257 return $response;
258 }
259
260 try {
261 $parsedResponse = $this->parseResponse( $rbody );
262 } catch ( EtcdConfigParseError $e ) {
263 $parsedResponse = [ 'error' => $e->getMessage() ];
264 }
265 return array_merge( $response, $parsedResponse );
266 }
267
274 protected function parseResponse( $rbody ) {
275 $info = json_decode( $rbody, true );
276 if ( $info === null ) {
277 throw new EtcdConfigParseError( "Error unserializing JSON response." );
278 }
279 if ( !isset( $info['node'] ) || !is_array( $info['node'] ) ) {
280 throw new EtcdConfigParseError(
281 "Unexpected JSON response: Missing or invalid node at top level." );
282 }
283 $config = [];
284 $lastModifiedIndex = $this->parseDirectory( '', $info['node'], $config );
285 return [ 'modifiedIndex' => $lastModifiedIndex, 'config' => $config ];
286 }
287
297 protected function parseDirectory( $dirName, $dirNode, &$config ) {
298 $lastModifiedIndex = 0;
299 if ( !isset( $dirNode['nodes'] ) ) {
300 throw new EtcdConfigParseError(
301 "Unexpected JSON response in dir '$dirName'; missing 'nodes' list." );
302 }
303 if ( !is_array( $dirNode['nodes'] ) ) {
304 throw new EtcdConfigParseError(
305 "Unexpected JSON response in dir '$dirName'; 'nodes' is not an array." );
306 }
307
308 foreach ( $dirNode['nodes'] as $node ) {
309 '@phan-var array $node';
310 $baseName = basename( $node['key'] );
311 $fullName = $dirName === '' ? $baseName : "$dirName/$baseName";
312 if ( !empty( $node['dir'] ) ) {
313 $lastModifiedIndex = max(
314 $this->parseDirectory( $fullName, $node, $config ),
315 $lastModifiedIndex );
316 } else {
317 $value = $this->unserialize( $node['value'] );
318 if ( !is_array( $value ) || !array_key_exists( 'val', $value ) ) {
319 throw new EtcdConfigParseError( "Failed to parse value for '$fullName'." );
320 }
321 $lastModifiedIndex = max( $node['modifiedIndex'], $lastModifiedIndex );
322 $config[$fullName] = $value['val'];
323 }
324 }
325 return $lastModifiedIndex;
326 }
327
332 private function unserialize( $string ) {
333 if ( $this->encoding === 'YAML' ) {
334 return yaml_parse( $string );
335 } else {
336 return json_decode( $string, true );
337 }
338 }
339}
unserialize( $serialized)
Class representing a cache/ephemeral data store.
Definition BagOStuff.php:86
Exceptions for config failures.
Interface for configuration instances.
string $encoding
int $skewCacheTTL
unserialize( $string)
string $host
string $protocol
MultiHttpClient $http
parseDirectory( $dirName, $dirNode, &$config)
Recursively parse a directory node and populate the array passed by reference, throwing EtcdConfigPar...
array $procCache
LoggerInterface $logger
string $directory
setLogger(LoggerInterface $logger)
__construct(array $params)
BagOStuff $srvCache
int $baseCacheTTL
has( $name)
Check whether a configuration option is set for the given name.
parseResponse( $rbody)
Parse a response body, throwing EtcdConfigParseError if there is a validation error.
fetchAllFromEtcdServer( $address)
Simple store for keeping values in an associative array for the current process.
Class to handle multiple HTTP requests.
Interface for configuration instances.
Definition Config.php:30
return true
Definition router.php:92