Aaron Schulz has uploaded a new change for review. (
https://gerrit.wikimedia.org/r/344058 )
Change subject: [WIP] Add EtcdConfig class
......................................................................
[WIP] Add EtcdConfig class
Bug: T156924
Change-Id: I60914d31c21484bfb935fe3d8c3168b51a2d5d1b
---
M autoload.php
M composer.json
A includes/config/EtcdConfig.php
3 files changed, 346 insertions(+), 1 deletion(-)
git pull ssh://gerrit.wikimedia.org:29418/mediawiki/core
refs/changes/58/344058/1
diff --git a/autoload.php b/autoload.php
index 4ffaa11..8b4c1cb 100644
--- a/autoload.php
+++ b/autoload.php
@@ -426,6 +426,7 @@
'EnqueueableDataUpdate' => __DIR__ .
'/includes/deferred/EnqueueableDataUpdate.php',
'EraseArchivedFile' => __DIR__ . '/maintenance/eraseArchivedFile.php',
'ErrorPageError' => __DIR__ . '/includes/exception/ErrorPageError.php',
+ 'EtcdConfig' => __DIR__ . '/includes/config/EtcdConfig.php',
'EventRelayer' => __DIR__ .
'/includes/libs/eventrelayer/EventRelayer.php',
'EventRelayerGroup' => __DIR__ . '/includes/EventRelayerGroup.php',
'EventRelayerKafka' => __DIR__ .
'/includes/libs/eventrelayer/EventRelayerKafka.php',
diff --git a/composer.json b/composer.json
index af8635a..14c1b5e 100644
--- a/composer.json
+++ b/composer.json
@@ -60,7 +60,8 @@
"wikimedia/avro": "1.7.7",
"hamcrest/hamcrest-php": "^2.0",
"wmde/hamcrest-html-matchers": "^0.1.0",
- "psy/psysh": "0.8.1"
+ "psy/psysh": "0.8.1",
+ "activecollab/etcd": "^1.0"
},
"suggest": {
"ext-apc": "Local data and opcode cache",
diff --git a/includes/config/EtcdConfig.php b/includes/config/EtcdConfig.php
new file mode 100644
index 0000000..7767451
--- /dev/null
+++ b/includes/config/EtcdConfig.php
@@ -0,0 +1,343 @@
+<?php
+/**
+ * Copyright 2017
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Aaron Schulz
+ */
+
+use ActiveCollab\Etcd\Client;
+use ActiveCollab\Etcd\Exception\EtcdException;
+
+/**
+ * Interface for configuration instances
+ *
+ * @since 1.29
+ */
+class EtcdConfig implements MutableConfig {
+ /** @var Client */
+ private $client;
+ /** @var BagOStuff */
+ private $cache;
+
+ /** @var string */
+ private $directory;
+ /** @var string */
+ private $encoding;
+ /** @var integer */
+ private $baseCacheTTL;
+ /** @var integer */
+ private $skewCacheTTL;
+ /** @var string */
+ private $directoryHash;
+
+ /**
+ * @param array $params Parameter map:
+ * - host: the host address and port
+ * - directory: the etc "directory" were MediaWiki specific variables
are located
+ * - encoding: one of ("JSON", "YAML")
+ * - cache: BagOStuff instance or ObjectFactory spec thereof for a
server cache.
+ * The cache will also be used as a fallback if etcd is down.
+ * - cacheTTL: logical cache TTL in seconds
+ * - skewTTL: maximum seconds to randomly lower the assigned TTL on
cache save
+ */
+ public function __construct( array $params ) {
+ $params += [ 'encoding' => 'JSON', 'cacheTTL' => 10, 'skewTTL'
=> 1 ];
+
+ $this->client = new Client( $params['host'] );
+ $this->directory = rtrim( $params['directory'], '/' );
+ $this->directoryHash = sha1( $this->directory );
+ $this->encoding = $params['encoding'];
+ $this->skewCacheTTL = $params['skewTTL'];
+ $this->baseCacheTTL = max( $params['cacheTTL'] -
$this->skewCacheTTL, 0 );
+
+ if ( !isset( $params['cache'] ) ) {
+ $this->cache = new HashBagOStuff( [] );
+ } elseif ( $params['cache'] instanceof BagOStuff ) {
+ $this->cache = $params['cache'];
+ } else {
+ $this->cache = ObjectFactory::getObjectFromSpec(
$params['cache'] );
+ }
+ }
+
+ public function get( $name ) {
+ $queryable = true;
+ $now = microtime( true );
+ $key = $this->cacheKey( $name );
+
+ $data = $this->cache->get( $key );
+ if ( !is_array( $data ) || $now > $data['exp'] ) {
+ // If there is no cached value or it is expired, try to
rebuild the cache
+ if ( $this->retrieveAndCacheAll() ) {
+ // If this succeeded, try to re-fetch the new
cached value
+ $data = $this->cache->get( $key );
+ } else {
+ $queryable = false; // lock is probably busy
+ }
+ }
+
+ if ( is_array( $data ) && $now < $data['exp'] ) {
+ $variable = $data['var']; // use cached value
+ } elseif ( $queryable ) {
+ try {
+ $variable = $this->retrieve( $name );
+
+ // Avoid having all servers expire cache keys
at the same time
+ $skew = mt_rand( 0, 1e6 ) / 1e6 *
$this->skewCacheTTL;
+ $expiry = microtime( true ) +
$this->baseCacheTTL + $skew;
+
+ $this->cache->set(
+ $key,
+ [ 'var' => $variable, 'exp' => $expiry
],
+ BagOStuff::TTL_INDEFINITE // logical
TTL only; useful for fallback
+ );
+ $data = $this->cache->get( $key );
+ } catch ( EtcdException $e ) {
+ if ( $data !== false ) {
+ // Fallback to the cached value
+ $variable = $data['var'];
+ } else {
+ // No cached value available; throw an
error
+ throw new ConfigException( "Got " .
get_class( $e ) . ": {$e->getMessage()}" );
+ }
+ }
+ } elseif ( $data !== false ) {
+ // Fallback to the cached value if the lock is likely
busy
+ $variable = $data['var'];
+ } else {
+ throw new ConfigException( "No cached entry for '$name'
and etcd is not queryable." );
+ }
+
+ return $variable;
+ }
+
+ public function set( $name, $value ) {
+ $encoded = $this->serialize( self::wrap( $value, true ) );
+ if ( !strlen( $encoded ) ) {
+ throw new ConfigException( "Failed to encode value for
'$name'." );
+ }
+
+ try {
+ $this->client->set( "{$this->directory}/{$name}",
$encoded );
+ } catch ( EtcdException $e ) {
+ throw new ConfigException( "Got " . get_class( $e ) .
": {$e->getMessage()}" );
+ }
+ }
+
+ /**
+ * @param string $name
+ * @return mixed
+ * @throws ConfigException
+ * @throws EtcdException
+ */
+ private function retrieve( $name ) {
+ $value = $this->client->get( "{$this->directory}/{$name}" );
+
+ $map = $this->unserialize( $value );
+ if ( !is_array( $map ) ) {
+ throw new ConfigException( "Value for '$name' failed to
decode." );
+ }
+
+ $this->assertHasKeys( $map, [ 'value', 'type' ] );
+
+ return self::unwrap( $map['value'], $map['type'] );
+ }
+
+ /**
+ * @param string $name
+ * @return bool Success
+ * @throws ConfigException
+ */
+ private function retrieveAndCacheAll() {
+ // Avoid re-cache stampedes to etcd. Let the anti-stampede lock
key expire on its
+ // own to handle the case when a caller keeps trying to get a
non-existant variable.
+ $lockKey = $this->cache->makeKey( 'recache-lock',
$this->directoryHash );
+ if ( !$this->cache->add( $lockKey, 1, $this->baseCacheTTL ) ) {
+ return false;
+ }
+
+ try {
+ // Retrieve all the values under the MediaWiki config
directory
+ $values = $this->client->getKeyValueMap(
"{$this->directory}/", false );
+ } catch ( EtcdException $e ) {
+ return false;
+ }
+
+ // Avoid having all servers expire cache keys at the same time
+ $expiry = microtime( true ) + $this->baseCacheTTL;
+ $expiry += mt_rand( 0, 1e6 ) / 1e6 * $this->skewCacheTTL;
+
+ foreach ( $values as $key => $value ) {
+ $name = basename( $key );
+
+ $map = $this->unserialize( $value );
+ if ( !is_array( $map ) ) {
+ throw new ConfigException( "Value for '$name'
failed to decode." );
+ }
+
+ $this->assertHasKeys( $map, [ 'value', 'type' ] );
+ $variable = self::unwrap( $map['value'], $map['type'] );
+
+ $this->cache->set(
+ $this->cacheKey( $name ),
+ [ 'var' => $variable, 'exp' => $expiry ],
+ BagOStuff::TTL_INDEFINITE // logical TTL only;
useful for fallback
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * @param string $name
+ * @return string
+ */
+ private function cacheKey( $name ) {
+ return $this->cache->makeKey( 'variable', $this->directoryHash,
$name );;
+ }
+
+ /**
+ * @param mixed $object
+ * @return string|bool
+ */
+ private function serialize( $object ) {
+ if ( $this->encoding === 'YAML' ) {
+ return yaml_emit( $object );
+ } else {
+ return json_encode( $object );
+ }
+ }
+
+ /**
+ * @param string $string
+ * @return mixed
+ */
+ private function unserialize( $string ) {
+ if ( $this->encoding === 'YAML' ) {
+ return yaml_parse( $string );
+ } else {
+ return json_decode( $string, true );
+ }
+ }
+
+ /**
+ * @param mixed $value
+ * @param bool $forceArray
+ * @return string
+ */
+ private function wrap( $value, $forceArray = false ) {
+ if ( self::isOrderedMap( $value ) ) {
+ // JSON/YAML don't have ordered maps, so use a list
with [key,value] entries
+ $result = [ 'value' => [], 'type' => 'omap' ];
+ foreach ( $value as $key => $element ) {
+ $entry = [ 'key' => $key ] + self::wrap(
$element, true );
+ $result['value'][] = $entry;
+ }
+ } elseif ( is_array( $value ) ) {
+ $result = [ 'value' => [], 'type' => 'native' ];
+ foreach ( $value as $element ) {
+ $result['value'][] = self::wrap( $element );
+ }
+ } elseif ( $forceArray ) {
+ // The outermost layer of a variable is forced as an
array
+ $result = [ 'value' => $value, 'type' => 'native' ];
+ } else {
+ $result = $value;
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param mixed $value
+ * @param string $type
+ * @return mixed
+ * @throws ConfigException
+ */
+ private function unwrap( $value, $type ) {
+ if ( $type === 'omap' ) {
+ if ( !is_array( $value ) || self::isOrderedMap( $value
) ) {
+ throw new ConfigException( "Expected 0-indexed
array for 'omap' value." );
+ }
+
+ $result = [];
+ foreach ( $value as $element ) {
+ $this->assertHasKeys( $element, [ 'key',
'value', 'type' ] );
+
+ $key = $element['key'];
+ if ( !is_string( $key ) && !is_int( $key ) ) {
+ throw new ConfigException( "Expected
integer or string key." );
+ }
+
+ $result[$key] = self::unwrap(
$element['value'], $element['type'] );
+ }
+ } elseif ( $type === 'native' ) {
+ if ( is_array( $value ) ) {
+ $result = [];
+ foreach ( $value as $element ) {
+ if ( is_array( $element ) ) {
+ $this->assertHasKeys( $element,
[ 'value', 'type' ] );
+
+ $result[] = self::unwrap(
$element['value'], $element['type'] );
+ } else {
+ $result[] = $element;
+ }
+ }
+ } else {
+ $result = $value;
+ }
+ } else {
+ throw new ConfigException( "Unexpected type '$type'." );
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param mixed $var
+ * @return bool
+ */
+ private function isOrderedMap( $var ) {
+ if ( !is_array( $var ) ) {
+ return false;
+ }
+
+ $i = 0;
+ foreach ( $var as $key => $unused ) {
+ if ( $key !== $i ) {
+ return true;
+ }
+ ++$i;
+ }
+
+ return false;
+ }
+
+ /**
+ * @param array $value
+ * @param array $keys
+ * @throws ConfigException
+ */
+ private function assertHasKeys( array $value, array $keys ) {
+ foreach ( $keys as $key ) {
+ if ( !array_key_exists( $key, $value ) ) {
+ throw new ConfigException( "Expected key '$key'
inside array." );
+ }
+ }
+ }
+}
--
To view, visit https://gerrit.wikimedia.org/r/344058
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings
Gerrit-MessageType: newchange
Gerrit-Change-Id: I60914d31c21484bfb935fe3d8c3168b51a2d5d1b
Gerrit-PatchSet: 1
Gerrit-Project: mediawiki/core
Gerrit-Branch: master
Gerrit-Owner: Aaron Schulz <[email protected]>
_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits