return $this->procCache['config'][$name];
}
+ public function getModifiedIndex() {
+ $this->load();
+ return $this->procCache['modifiedIndex'];
+ }
+
/**
* @throws ConfigException
*/
// refresh the cache from etcd, using a mutex to reduce stampedes...
if ( $this->srvCache->lock( $key, 0, $this->baseCacheTTL ) ) {
try {
- list( $config, $error, $retry ) = $this->fetchAllFromEtcd();
- if ( is_array( $config ) ) {
+ $etcdResponse = $this->fetchAllFromEtcd();
+ $error = $etcdResponse['error'];
+ if ( is_array( $etcdResponse['config'] ) ) {
// Avoid having all servers expire cache keys at the same time
$expiry = microtime( true ) + $this->baseCacheTTL;
$expiry += mt_rand( 0, 1e6 ) / 1e6 * $this->skewCacheTTL;
-
- $data = [ 'config' => $config, 'expires' => $expiry ];
+ $data = [
+ 'config' => $etcdResponse['config'],
+ 'expires' => $expiry,
+ 'modifiedIndex' => $etcdResponse['modifiedIndex']
+ ];
$this->srvCache->set( $key, $data, BagOStuff::TTL_INDEFINITE );
$this->logger->info( "Refreshed stale etcd configuration cache." );
return WaitConditionLoop::CONDITION_REACHED;
} else {
$this->logger->error( "Failed to fetch configuration: $error" );
- if ( !$retry ) {
+ if ( !$etcdResponse['retry'] ) {
// Fail fast since the error is likely to keep happening
return WaitConditionLoop::CONDITION_FAILED;
}
}
/**
- * @return array (config array or null, error string, allow retries)
+ * @return array (containing the keys config, error, retry, modifiedIndex)
*/
public function fetchAllFromEtcd() {
+ // TODO: inject DnsSrvDiscoverer in order to be able to test this method
$dsd = new DnsSrvDiscoverer( $this->host );
$servers = $dsd->getServers();
if ( !$servers ) {
$server = $dsd->pickServer( $servers );
$host = IP::combineHostAndPort( $server['target'], $server['port'] );
// Try to load the config from this particular server
- list( $config, $error, $retry ) = $this->fetchAllFromEtcdServer( $host );
- if ( is_array( $config ) || !$retry ) {
+ $response = $this->fetchAllFromEtcdServer( $host );
+ if ( is_array( $response['config'] ) || $response['retry'] ) {
break;
}
$servers = $dsd->removeServer( $server, $servers );
} while ( $servers );
- return [ $config, $error, $retry ];
+ return $response;
}
/**
* @param string $address Host and port
- * @return array (config array or null, error string, whether to allow retries)
+ * @return array (containing the keys config, error, retry, modifiedIndex)
*/
protected function fetchAllFromEtcdServer( $address ) {
// Retrieve all the values under the MediaWiki config directory
'headers' => [ 'content-type' => 'application/json' ]
] );
+ $response = [ 'config' => null, 'error' => null, 'retry' => false, 'modifiedIndex' => 0 ];
+
static $terminalCodes = [ 404 => true ];
if ( $rcode < 200 || $rcode > 399 ) {
- return [
- null,
- strlen( $rerr ) ? $rerr : "HTTP $rcode ($rdesc)",
- empty( $terminalCodes[$rcode] )
- ];
+ $response['error'] = strlen( $rerr ) ? $rerr : "HTTP $rcode ($rdesc)";
+ $response['retry'] = empty( $terminalCodes[$rcode] );
+ return $response;
}
+
try {
- return [ $this->parseResponse( $rbody ), null, false ];
+ $parsedResponse = $this->parseResponse( $rbody );
} catch ( EtcdConfigParseError $e ) {
- return [ null, $e->getMessage(), false ];
+ $parsedResponse = [ 'error' => $e->getMessage() ];
}
+ return array_merge( $response, $parsedResponse );
}
/**
"Unexpected JSON response: Missing or invalid node at top level." );
}
$config = [];
- $this->parseDirectory( '', $info['node'], $config );
- return $config;
+ $lastModifiedIndex = $this->parseDirectory( '', $info['node'], $config );
+ return [ 'modifiedIndex' => $lastModifiedIndex, 'config' => $config ];
}
/**
* @param string $dirName The relative directory name
* @param array $dirNode The decoded directory node
* @param array &$config The output array
+ * @return int lastModifiedIndex The maximum last modified index across all keys in the directory
*/
protected function parseDirectory( $dirName, $dirNode, &$config ) {
+ $lastModifiedIndex = 0;
if ( !isset( $dirNode['nodes'] ) ) {
throw new EtcdConfigParseError(
"Unexpected JSON response in dir '$dirName'; missing 'nodes' list." );
$baseName = basename( $node['key'] );
$fullName = $dirName === '' ? $baseName : "$dirName/$baseName";
if ( !empty( $node['dir'] ) ) {
- $this->parseDirectory( $fullName, $node, $config );
+ $lastModifiedIndex = max(
+ $this->parseDirectory( $fullName, $node, $config ),
+ $lastModifiedIndex );
} else {
$value = $this->unserialize( $node['value'] );
if ( !is_array( $value ) || !array_key_exists( 'val', $value ) ) {
throw new EtcdConfigParseError( "Failed to parse value for '$fullName'." );
}
-
+ $lastModifiedIndex = max( $node['modifiedIndex'], $lastModifiedIndex );
$config[$fullName] = $value['val'];
}
}
+ return $lastModifiedIndex;
}
/**
->getMock();
}
- private function createSimpleConfigMock( array $config ) {
+ private static function createEtcdResponse( array $response ) {
+ $baseResponse = [
+ 'config' => null,
+ 'error' => null,
+ 'retry' => false,
+ 'modifiedIndex' => 0,
+ ];
+ return array_merge( $baseResponse, $response );
+ }
+
+ private function createSimpleConfigMock( array $config, $index = 0 ) {
$mock = $this->createConfigMock();
$mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
- ->willReturn( [
- $config,
- null, // error
- false // retry?
- ] );
+ ->willReturn( self::createEtcdResponse( [
+ 'config' => $config,
+ 'modifiedIndex' => $index,
+ ] ) );
return $mock;
}
$config->get( 'unknown' );
}
+ /**
+ * @covers EtcdConfig::getModifiedIndex
+ */
+ public function testGetModifiedIndex() {
+ $config = $this->createSimpleConfigMock(
+ [ 'some' => 'value' ],
+ 123
+ );
+ $this->assertSame( 123, $config->getModifiedIndex() );
+ }
+
/**
* @covers EtcdConfig::__construct
*/
->willReturn( [
'config' => [ 'known' => 'from-cache' ],
'expires' => INF,
+ 'modifiedIndex' => 123
] );
$config = $this->createConfigMock( [ 'cache' => $cache ] );
'class' => HashBagOStuff::class
] ] );
$config->expects( $this->once() )->method( 'fetchAllFromEtcd' )
- ->willReturn( [
- [ 'known' => 'from-fetch' ],
- null, // error
- false // retry?
- ] );
+ ->willReturn( self::createEtcdResponse(
+ [ 'config' => [ 'known' => 'from-fetch' ], ] ) );
$this->assertSame( 'from-fetch', $config->get( 'known' ) );
}
'cache' => $cache,
] );
$mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
- ->willReturn( [ [ 'known' => 'from-fetch' ], null, false ] );
+ ->willReturn(
+ self::createEtcdResponse( [ 'config' => [ 'known' => 'from-fetch' ] ] ) );
$this->assertSame( 'from-fetch', $mock->get( 'known' ) );
}
'cache' => $cache,
] );
$mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
- ->willReturn( [ null, 'Fake error', false ] );
+ ->willReturn( self::createEtcdResponse( [ 'error' => 'Fake error', ] ) );
$this->setExpectedException( ConfigException::class );
$mock->get( 'key' );
[
'config' => [ 'known' => 'from-cache' ],
'expires' => INF,
+ 'modifiedIndex' => 123
]
) );
// .. misses lock
->willReturn( [
'config' => [ 'known' => 'from-cache' ],
'expires' => INF,
+ 'modifiedIndex' => 0,
] );
$cache->expects( $this->never() )->method( 'lock' );
->willReturn( [
'config' => [ 'known' => 'from-cache' ],
'expires' => INF,
+ 'modifiedIndex' => 0,
] );
$cache->expects( $this->never() )->method( 'lock' );
[
'config' => [ 'known' => 'from-cache-expired' ],
'expires' => -INF,
+ 'modifiedIndex' => 0,
]
);
// .. gets lock
'cache' => $cache,
] );
$mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
- ->willReturn( [ [ 'known' => 'from-fetch' ], null, false ] );
+ ->willReturn( self::createEtcdResponse( [ 'config' => [ 'known' => 'from-fetch' ] ] ) );
$this->assertSame( 'from-fetch', $mock->get( 'known' ) );
}
[
'config' => [ 'known' => 'from-cache-expired' ],
'expires' => -INF,
+ 'modifiedIndex' => 0,
]
);
// .. gets lock
'cache' => $cache,
] );
$mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
- ->willReturn( [ null, 'Fake failure', true ] );
+ ->willReturn( self::createEtcdResponse( [ 'error' => 'Fake failure', 'retry' => true ] ) );
$this->assertSame( 'from-cache-expired', $mock->get( 'known' ) );
}
->willReturn( [
'config' => [ 'known' => 'from-cache-expired' ],
'expires' => -INF,
+ 'modifiedIndex' => 0,
] );
// .. misses lock
$cache->expects( $this->once() )->method( 'lock' )
'body' => json_encode( [ 'node' => [ 'nodes' => [
[
'key' => '/example/foo',
- 'value' => json_encode( [ 'val' => true ] )
+ 'value' => json_encode( [ 'val' => true ] ),
+ 'modifiedIndex' => 123
],
] ] ] ),
'error' => '',
],
- 'expect' => [
- [ 'foo' => true ], // data
- null,
- false // retry
- ],
+ 'expect' => self::createEtcdResponse( [
+ 'config' => [ 'foo' => true ], // data
+ 'modifiedIndex' => 123
+ ] ),
],
'200 OK - Empty dir' => [
'http' => [
'body' => json_encode( [ 'node' => [ 'nodes' => [
[
'key' => '/example/foo',
- 'value' => json_encode( [ 'val' => true ] )
+ 'value' => json_encode( [ 'val' => true ] ),
+ 'modifiedIndex' => 123
],
[
'key' => '/example/sub',
'dir' => true,
+ 'modifiedIndex' => 234,
'nodes' => [],
],
[
'key' => '/example/bar',
- 'value' => json_encode( [ 'val' => false ] )
+ 'value' => json_encode( [ 'val' => false ] ),
+ 'modifiedIndex' => 125
],
] ] ] ),
'error' => '',
],
- 'expect' => [
- [ 'foo' => true, 'bar' => false ], // data
- null,
- false // retry
- ],
+ 'expect' => self::createEtcdResponse( [
+ 'config' => [ 'foo' => true, 'bar' => false ], // data
+ 'modifiedIndex' => 125 // largest modified index
+ ] ),
],
'200 OK - Recursive' => [
'http' => [
[
'key' => '/example/a',
'dir' => true,
+ 'modifiedIndex' => 124,
'nodes' => [
[
'key' => 'b',
'value' => json_encode( [ 'val' => true ] ),
+ 'modifiedIndex' => 123,
+
],
[
'key' => 'c',
'value' => json_encode( [ 'val' => false ] ),
+ 'modifiedIndex' => 123,
],
],
],
] ] ] ),
'error' => '',
],
- 'expect' => [
- [ 'a/b' => true, 'a/c' => false ], // data
- null,
- false // retry
- ],
+ 'expect' => self::createEtcdResponse( [
+ 'config' => [ 'a/b' => true, 'a/c' => false ], // data
+ 'modifiedIndex' => 123 // largest modified index
+ ] ),
],
'200 OK - Missing nodes at second level' => [
'http' => [
[
'key' => '/example/a',
'dir' => true,
+ 'modifiedIndex' => 0,
],
] ] ] ),
'error' => '',
],
- 'expect' => [
- null,
- "Unexpected JSON response in dir 'a'; missing 'nodes' list.",
- false // retry
- ],
+ 'expect' => self::createEtcdResponse( [
+ 'error' => "Unexpected JSON response in dir 'a'; missing 'nodes' list.",
+ ] ),
],
'200 OK - Directory with non-array "nodes" key' => [
'http' => [
] ] ] ),
'error' => '',
],
- 'expect' => [
- null,
- "Unexpected JSON response in dir 'a'; 'nodes' is not an array.",
- false // retry
- ],
+ 'expect' => self::createEtcdResponse( [
+ 'error' => "Unexpected JSON response in dir 'a'; 'nodes' is not an array.",
+ ] ),
],
'200 OK - Correctly encoded garbage response' => [
'http' => [
'body' => json_encode( [ 'foo' => 'bar' ] ),
'error' => '',
],
- 'expect' => [
- null,
- "Unexpected JSON response: Missing or invalid node at top level.",
- false // retry
- ],
+ 'expect' => self::createEtcdResponse( [
+ 'error' => "Unexpected JSON response: Missing or invalid node at top level.",
+ ] ),
],
'200 OK - Bad value' => [
'http' => [
'body' => json_encode( [ 'node' => [ 'nodes' => [
[
'key' => '/example/foo',
- 'value' => ';"broken{value'
+ 'value' => ';"broken{value',
+ 'modifiedIndex' => 123,
]
] ] ] ),
'error' => '',
],
- 'expect' => [
- null, // data
- "Failed to parse value for 'foo'.",
- false // retry
- ],
+ 'expect' => self::createEtcdResponse( [
+ 'error' => "Failed to parse value for 'foo'.",
+ ] ),
],
'200 OK - Empty node list' => [
'http' => [
'code' => 200,
'reason' => 'OK',
'headers' => [],
- 'body' => '{"node":{"nodes":[]}}',
+ 'body' => '{"node":{"nodes":[], "modifiedIndex": 12 }}',
'error' => '',
],
- 'expect' => [
- [], // data
- null,
- false // retry
- ],
+ 'expect' => self::createEtcdResponse( [
+ 'config' => [], // data
+ ] ),
],
'200 OK - Invalid JSON' => [
'http' => [
'body' => '',
'error' => '(curl error: no status set)',
],
- 'expect' => [
- null, // data
- "Error unserializing JSON response.",
- false // retry
- ],
+ 'expect' => self::createEtcdResponse( [
+ 'error' => "Error unserializing JSON response.",
+ ] ),
],
'404 Not Found' => [
'http' => [
'body' => '',
'error' => '',
],
- 'expect' => [
- null, // data
- 'HTTP 404 (Not Found)',
- false // retry
- ],
+ 'expect' => self::createEtcdResponse( [
+ 'error' => 'HTTP 404 (Not Found)',
+ ] ),
],
'400 Bad Request - custom error' => [
'http' => [
'body' => '',
'error' => 'No good reason',
],
- 'expect' => [
- null, // data
- 'No good reason',
- true // retry
- ],
+ 'expect' => self::createEtcdResponse( [
+ 'error' => 'No good reason',
+ 'retry' => true, // retry
+ ] ),
],
];
}