Merge "mediawiki.skinning: Vertical alignment for traditional galleries in Parsoid...
[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\WaitConditionLoop;
24
25 /**
26 * Interface for configuration instances
27 *
28 * @since 1.29
29 */
30 class EtcdConfig implements Config, LoggerAwareInterface {
31 /** @var MultiHttpClient */
32 private $http;
33 /** @var BagOStuff */
34 private $srvCache;
35 /** @var array */
36 private $procCache;
37 /** @var LoggerInterface */
38 private $logger;
39
40 /** @var string */
41 private $host;
42 /** @var string */
43 private $protocol;
44 /** @var string */
45 private $directory;
46 /** @var string */
47 private $encoding;
48 /** @var int */
49 private $baseCacheTTL;
50 /** @var int */
51 private $skewCacheTTL;
52 /** @var int */
53 private $timeout;
54
55 /**
56 * @param array $params Parameter map:
57 * - host: the host address and port
58 * - protocol: either http or https
59 * - directory: the etc "directory" were MediaWiki specific variables are located
60 * - encoding: one of ("JSON", "YAML"). Defaults to JSON. [optional]
61 * - cache: BagOStuff instance or ObjectFactory spec thereof for a server cache.
62 * The cache will also be used as a fallback if etcd is down. [optional]
63 * - cacheTTL: logical cache TTL in seconds [optional]
64 * - skewTTL: maximum seconds to randomly lower the assigned TTL on cache save [optional]
65 * - timeout: seconds to wait for etcd before throwing an error [optional]
66 */
67 public function __construct( array $params ) {
68 $params += [
69 'protocol' => 'http',
70 'encoding' => 'JSON',
71 'cacheTTL' => 10,
72 'skewTTL' => 1,
73 'timeout' => 2
74 ];
75
76 $this->host = $params['host'];
77 $this->protocol = $params['protocol'];
78 $this->directory = trim( $params['directory'], '/' );
79 $this->encoding = $params['encoding'];
80 $this->skewCacheTTL = $params['skewTTL'];
81 $this->baseCacheTTL = max( $params['cacheTTL'] - $this->skewCacheTTL, 0 );
82 $this->timeout = $params['timeout'];
83
84 if ( !isset( $params['cache'] ) ) {
85 $this->srvCache = new HashBagOStuff();
86 } elseif ( $params['cache'] instanceof BagOStuff ) {
87 $this->srvCache = $params['cache'];
88 } else {
89 $this->srvCache = ObjectFactory::getObjectFromSpec( $params['cache'] );
90 }
91
92 $this->logger = new Psr\Log\NullLogger();
93 $this->http = new MultiHttpClient( [
94 'connTimeout' => $this->timeout,
95 'reqTimeout' => $this->timeout,
96 'logger' => $this->logger
97 ] );
98 }
99
100 public function setLogger( LoggerInterface $logger ) {
101 $this->logger = $logger;
102 $this->http->setLogger( $logger );
103 }
104
105 public function has( $name ) {
106 $this->load();
107
108 return array_key_exists( $name, $this->procCache['config'] );
109 }
110
111 public function get( $name ) {
112 $this->load();
113
114 if ( !array_key_exists( $name, $this->procCache['config'] ) ) {
115 throw new ConfigException( "No entry found for '$name'." );
116 }
117
118 return $this->procCache['config'][$name];
119 }
120
121 /**
122 * @throws ConfigException
123 */
124 private function load() {
125 if ( $this->procCache !== null ) {
126 return; // already loaded
127 }
128
129 $now = microtime( true );
130 $key = $this->srvCache->makeGlobalKey(
131 __CLASS__,
132 $this->host,
133 $this->directory
134 );
135
136 // Get the cached value or block until it is regenerated (by this or another thread)...
137 $data = null; // latest config info
138 $error = null; // last error message
139 $loop = new WaitConditionLoop(
140 function () use ( $key, $now, &$data, &$error ) {
141 // Check if the values are in cache yet...
142 $data = $this->srvCache->get( $key );
143 if ( is_array( $data ) && $data['expires'] > $now ) {
144 $this->logger->debug( "Found up-to-date etcd configuration cache." );
145
146 return WaitConditionLoop::CONDITION_REACHED;
147 }
148
149 // Cache is either empty or stale;
150 // refresh the cache from etcd, using a mutex to reduce stampedes...
151 if ( $this->srvCache->lock( $key, 0, $this->baseCacheTTL ) ) {
152 try {
153 list( $config, $error, $retry ) = $this->fetchAllFromEtcd();
154 if ( is_array( $config ) ) {
155 // Avoid having all servers expire cache keys at the same time
156 $expiry = microtime( true ) + $this->baseCacheTTL;
157 $expiry += mt_rand( 0, 1e6 ) / 1e6 * $this->skewCacheTTL;
158
159 $data = [ 'config' => $config, 'expires' => $expiry ];
160 $this->srvCache->set( $key, $data, BagOStuff::TTL_INDEFINITE );
161
162 $this->logger->info( "Refreshed stale etcd configuration cache." );
163
164 return WaitConditionLoop::CONDITION_REACHED;
165 } else {
166 $this->logger->error( "Failed to fetch configuration: $error" );
167 if ( !$retry ) {
168 // Fail fast since the error is likely to keep happening
169 return WaitConditionLoop::CONDITION_FAILED;
170 }
171 }
172 } finally {
173 $this->srvCache->unlock( $key ); // release mutex
174 }
175 }
176
177 if ( is_array( $data ) ) {
178 $this->logger->info( "Using stale etcd configuration cache." );
179
180 return WaitConditionLoop::CONDITION_REACHED;
181 }
182
183 return WaitConditionLoop::CONDITION_CONTINUE;
184 },
185 $this->timeout
186 );
187
188 if ( $loop->invoke() !== WaitConditionLoop::CONDITION_REACHED ) {
189 // No cached value exists and etcd query failed; throw an error
190 throw new ConfigException( "Failed to load configuration from etcd: $error" );
191 }
192
193 $this->procCache = $data;
194 }
195
196 /**
197 * @return array (config array or null, error string, allow retries)
198 */
199 public function fetchAllFromEtcd() {
200 $dsd = new DnsSrvDiscoverer( $this->host );
201 $servers = $dsd->getServers();
202 if ( !$servers ) {
203 return $this->fetchAllFromEtcdServer( $this->host );
204 }
205
206 do {
207 // Pick a random etcd server from dns
208 $server = $dsd->pickServer( $servers );
209 $host = IP::combineHostAndPort( $server['target'], $server['port'] );
210 // Try to load the config from this particular server
211 list( $config, $error, $retry ) = $this->fetchAllFromEtcdServer( $host );
212 if ( is_array( $config ) || !$retry ) {
213 break;
214 }
215
216 // Avoid the server next time if that failed
217 $servers = $dsd->removeServer( $server, $servers );
218 } while ( $servers );
219
220 return [ $config, $error, $retry ];
221 }
222
223 /**
224 * @param string $address Host and port
225 * @return array (config array or null, error string, whether to allow retries)
226 */
227 protected function fetchAllFromEtcdServer( $address ) {
228 // Retrieve all the values under the MediaWiki config directory
229 list( $rcode, $rdesc, /* $rhdrs */, $rbody, $rerr ) = $this->http->run( [
230 'method' => 'GET',
231 'url' => "{$this->protocol}://{$address}/v2/keys/{$this->directory}/",
232 'headers' => [ 'content-type' => 'application/json' ]
233 ] );
234
235 static $terminalCodes = [ 404 => true ];
236 if ( $rcode < 200 || $rcode > 399 ) {
237 return [
238 null,
239 strlen( $rerr ) ? $rerr : "HTTP $rcode ($rdesc)",
240 empty( $terminalCodes[$rcode] )
241 ];
242 }
243
244 $info = json_decode( $rbody, true );
245 if ( $info === null || !isset( $info['node']['nodes'] ) ) {
246 return [ null, "Unexpected JSON response; missing 'nodes' list.", false ];
247 }
248
249 $config = [];
250 foreach ( $info['node']['nodes'] as $node ) {
251 if ( !empty( $node['dir'] ) ) {
252 continue; // skip directories
253 }
254
255 $name = basename( $node['key'] );
256 $value = $this->unserialize( $node['value'] );
257 if ( !is_array( $value ) || !array_key_exists( 'val', $value ) ) {
258 return [ null, "Failed to parse value for '$name'.", false ];
259 }
260
261 $config[$name] = $value['val'];
262 }
263
264 return [ $config, null, false ];
265 }
266
267 /**
268 * @param string $string
269 * @return mixed
270 */
271 private function unserialize( $string ) {
272 if ( $this->encoding === 'YAML' ) {
273 return yaml_parse( $string );
274 } else { // JSON
275 return json_decode( $string, true );
276 }
277 }
278 }