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