Add randomized RPC endpoint pooling and failover
This commit is contained in:
parent
9808302ba8
commit
d1f13d2acb
77
README.md
77
README.md
|
|
@ -44,10 +44,10 @@ https://contractless.dev/contractless/Contractless-PHP-Modules
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Install version `0.2` through Packagist:
|
Install version `0.3` through Packagist:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
composer require contractless/contractless-php-rpc:^0.2.0 -W
|
composer require contractless/contractless-php-rpc:^0.3.0 -W
|
||||||
```
|
```
|
||||||
|
|
||||||
Composer installs the package under `vendor/` and generates the required
|
Composer installs the package under `vendor/` and generates the required
|
||||||
|
|
@ -89,6 +89,79 @@ mainnet RPC unless the node operator configured another port.
|
||||||
Every RPC call performs a signed client handshake. The wallet used for the
|
Every RPC call performs a signed client handshake. The wallet used for the
|
||||||
handshake does not have to own an address being looked up.
|
handshake does not have to own an address being looked up.
|
||||||
|
|
||||||
|
## RPC Endpoint Pool
|
||||||
|
|
||||||
|
Contractless client RPC is intentionally one request per connection. A node
|
||||||
|
accepts the client connection, performs the authenticated handshake, returns
|
||||||
|
one RPC reply, and closes the connection.
|
||||||
|
|
||||||
|
`EndpointPoolTransport` is therefore a pool of available endpoints, not a
|
||||||
|
pool of persistent sockets. It randomly selects a new endpoint for every RPC
|
||||||
|
call so applications can distribute their requests across several nodes:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Contractless\Rpc\Transport\EndpointPoolTransport;
|
||||||
|
use Contractless\Rpc\Transport\StreamTransport;
|
||||||
|
|
||||||
|
$transport = new EndpointPoolTransport([
|
||||||
|
'local-node' => new StreamTransport(
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 50050,
|
||||||
|
timeout: 10.0,
|
||||||
|
),
|
||||||
|
'public-node-1' => new StreamTransport(
|
||||||
|
host: 'node1.example.com',
|
||||||
|
port: 50050,
|
||||||
|
timeout: 10.0,
|
||||||
|
),
|
||||||
|
'public-node-2' => new StreamTransport(
|
||||||
|
host: 'node2.example.com',
|
||||||
|
port: 50050,
|
||||||
|
timeout: 10.0,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$client = new Client(
|
||||||
|
$transport,
|
||||||
|
$crypto,
|
||||||
|
$credentials,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
For every `Client` call, the pool:
|
||||||
|
|
||||||
|
1. Randomizes the configured endpoint order.
|
||||||
|
2. Opens a one-shot RPC connection to the first selected endpoint.
|
||||||
|
3. Returns its complete reply when successful.
|
||||||
|
4. Tries the next randomized endpoint only after a `TransportException`.
|
||||||
|
5. Throws one `TransportException` identifying every attempted endpoint when
|
||||||
|
all transports fail.
|
||||||
|
|
||||||
|
Protocol errors and node validation replies do not trigger failover. Those
|
||||||
|
responses may indicate that the request itself is invalid, so sending it to
|
||||||
|
additional nodes would add unnecessary traffic.
|
||||||
|
|
||||||
|
The endpoint that most recently completed a request is available through:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$endpointId = $transport->lastSuccessfulEndpointId();
|
||||||
|
```
|
||||||
|
|
||||||
|
Linked operations that must return to one endpoint can retrieve its configured
|
||||||
|
transport explicitly:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$selectedTransport = $transport->transportFor($endpointId);
|
||||||
|
$selectedClient = new Client(
|
||||||
|
$selectedTransport,
|
||||||
|
$crypto,
|
||||||
|
$credentials,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
This is needed for operations such as paid storage lookup, where the fee quote,
|
||||||
|
payment address, and final lookup belong to the node that issued the quote.
|
||||||
|
|
||||||
## RPC Replies
|
## RPC Replies
|
||||||
|
|
||||||
RPC methods return the node's original reply as a PHP string. Depending on the
|
RPC methods return the node's original reply as a PHP string. Depending on the
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Contractless\Rpc\Transport;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Contractless\Rpc\Exception\TransportException;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Randomly distributes one-shot RPC calls across named transports.
|
||||||
|
*
|
||||||
|
* Each contained StreamTransport still opens a new socket, performs one
|
||||||
|
* authenticated request, receives one reply, and closes the socket. The pool
|
||||||
|
* stores endpoints, not persistent connections.
|
||||||
|
*/
|
||||||
|
final class EndpointPoolTransport implements TransportInterface
|
||||||
|
{
|
||||||
|
/** @var array<string, TransportInterface> */
|
||||||
|
private array $transports;
|
||||||
|
|
||||||
|
private ?string $lastSuccessfulEndpointId = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, TransportInterface> $transports
|
||||||
|
*/
|
||||||
|
public function __construct(array $transports, private readonly bool $randomize = true)
|
||||||
|
{
|
||||||
|
if ($transports === []) {
|
||||||
|
throw new InvalidArgumentException('The RPC endpoint pool cannot be empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($transports as $endpointId => $transport) {
|
||||||
|
if (!is_string($endpointId) || trim($endpointId) === '') {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'Every RPC endpoint must have a non-empty string identifier.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!$transport instanceof TransportInterface) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
"RPC endpoint $endpointId does not implement TransportInterface.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->transports = $transports;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exchange(
|
||||||
|
string $handshake,
|
||||||
|
string $request,
|
||||||
|
string $uid,
|
||||||
|
Closure $validateHandshake,
|
||||||
|
): string {
|
||||||
|
$this->lastSuccessfulEndpointId = null;
|
||||||
|
$endpointIds = array_keys($this->transports);
|
||||||
|
if ($this->randomize && count($endpointIds) > 1) {
|
||||||
|
shuffle($endpointIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
$lastFailure = null;
|
||||||
|
|
||||||
|
foreach ($endpointIds as $endpointId) {
|
||||||
|
try {
|
||||||
|
$reply = $this->transports[$endpointId]->exchange(
|
||||||
|
$handshake,
|
||||||
|
$request,
|
||||||
|
$uid,
|
||||||
|
$validateHandshake,
|
||||||
|
);
|
||||||
|
$this->lastSuccessfulEndpointId = $endpointId;
|
||||||
|
return $reply;
|
||||||
|
} catch (TransportException $error) {
|
||||||
|
// Only transport failures move the same request to another
|
||||||
|
// endpoint. Protocol and validation errors remain final.
|
||||||
|
$failures[] = "$endpointId: {$error->getMessage()}";
|
||||||
|
$lastFailure = $error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TransportException(
|
||||||
|
'Every Contractless RPC endpoint failed: ' . implode('; ', $failures),
|
||||||
|
previous: $lastFailure,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a specific transport for a linked request that must use the same
|
||||||
|
* endpoint as an earlier operation, such as a paid storage lookup.
|
||||||
|
*/
|
||||||
|
public function transportFor(string $endpointId): TransportInterface
|
||||||
|
{
|
||||||
|
if (!isset($this->transports[$endpointId])) {
|
||||||
|
throw new InvalidArgumentException("Unknown RPC endpoint: $endpointId");
|
||||||
|
}
|
||||||
|
return $this->transports[$endpointId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public function endpointIds(): array
|
||||||
|
{
|
||||||
|
return array_keys($this->transports);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lastSuccessfulEndpointId(): ?string
|
||||||
|
{
|
||||||
|
return $this->lastSuccessfulEndpointId;
|
||||||
|
}
|
||||||
|
}
|
||||||
114
tests/run.php
114
tests/run.php
|
|
@ -17,8 +17,11 @@ spl_autoload_register(static function (string $class): void {
|
||||||
|
|
||||||
use Contractless\Rpc\Client;
|
use Contractless\Rpc\Client;
|
||||||
use Contractless\Rpc\Crypto\CallbackCrypto;
|
use Contractless\Rpc\Crypto\CallbackCrypto;
|
||||||
|
use Contractless\Rpc\Exception\ProtocolException;
|
||||||
|
use Contractless\Rpc\Exception\TransportException;
|
||||||
use Contractless\Rpc\Protocol\Address;
|
use Contractless\Rpc\Protocol\Address;
|
||||||
use Contractless\Rpc\Protocol\Command;
|
use Contractless\Rpc\Protocol\Command;
|
||||||
|
use Contractless\Rpc\Transport\EndpointPoolTransport;
|
||||||
use Contractless\Rpc\Transport\TransportInterface;
|
use Contractless\Rpc\Transport\TransportInterface;
|
||||||
use Contractless\Rpc\Transaction\TransferBuilder;
|
use Contractless\Rpc\Transaction\TransferBuilder;
|
||||||
use Contractless\Rpc\Transaction\AssetBuilder;
|
use Contractless\Rpc\Transaction\AssetBuilder;
|
||||||
|
|
@ -36,6 +39,30 @@ function expect(bool $condition, string $message): void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class TestTransport implements TransportInterface
|
||||||
|
{
|
||||||
|
public int $calls = 0;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ?string $reply = null,
|
||||||
|
private readonly ?Throwable $failure = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exchange(
|
||||||
|
string $handshake,
|
||||||
|
string $request,
|
||||||
|
string $uid,
|
||||||
|
Closure $validateHandshake,
|
||||||
|
): string {
|
||||||
|
$this->calls++;
|
||||||
|
if ($this->failure !== null) {
|
||||||
|
throw $this->failure;
|
||||||
|
}
|
||||||
|
return $this->reply ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
Address::toBytes(str_repeat('ab', 20) . '.cltc')
|
Address::toBytes(str_repeat('ab', 20) . '.cltc')
|
||||||
=== str_repeat("\xab", 20) . ".\x02",
|
=== str_repeat("\xab", 20) . ".\x02",
|
||||||
|
|
@ -138,4 +165,91 @@ $loan = $agreements->loan('CLTC', 1000, $address, 'TEST', 2000, str_repeat('cd',
|
||||||
$loan = $loan->signNext($crypto, $credentials);
|
$loan = $loan->signNext($crypto, $credentials);
|
||||||
expect(strlen($loan->toSignedTransaction()->bytes) === 1492, 'Loan size is wrong.');
|
expect(strlen($loan->toSignedTransaction()->bytes) === 1492, 'Loan size is wrong.');
|
||||||
|
|
||||||
|
$failedEndpoint = new TestTransport(
|
||||||
|
failure: new TransportException('Connection refused.'),
|
||||||
|
);
|
||||||
|
$workingEndpoint = new TestTransport(reply: 'pool reply');
|
||||||
|
$endpointPool = new EndpointPoolTransport(
|
||||||
|
[
|
||||||
|
'offline-node' => $failedEndpoint,
|
||||||
|
'working-node' => $workingEndpoint,
|
||||||
|
],
|
||||||
|
randomize: false,
|
||||||
|
);
|
||||||
|
$pooledClient = new Client($endpointPool, $crypto, $credentials);
|
||||||
|
expect(
|
||||||
|
$pooledClient->blockHeight() === 'pool reply',
|
||||||
|
'Endpoint pool did not return the successful fallback reply.',
|
||||||
|
);
|
||||||
|
expect($failedEndpoint->calls === 1, 'Endpoint pool did not try the first endpoint.');
|
||||||
|
expect($workingEndpoint->calls === 1, 'Endpoint pool did not try the fallback endpoint.');
|
||||||
|
expect(
|
||||||
|
$endpointPool->lastSuccessfulEndpointId() === 'working-node',
|
||||||
|
'Endpoint pool did not record the endpoint that answered.',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
$endpointPool->endpointIds() === ['offline-node', 'working-node'],
|
||||||
|
'Endpoint pool did not preserve its endpoint identifiers.',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
$endpointPool->transportFor('working-node') === $workingEndpoint,
|
||||||
|
'Endpoint pool could not return a specifically requested transport.',
|
||||||
|
);
|
||||||
|
|
||||||
|
$protocolFailure = new TestTransport(
|
||||||
|
failure: new ProtocolException('Invalid RPC reply.'),
|
||||||
|
);
|
||||||
|
$unusedEndpoint = new TestTransport(reply: 'must not run');
|
||||||
|
$protocolPool = new EndpointPoolTransport(
|
||||||
|
[
|
||||||
|
'invalid-node' => $protocolFailure,
|
||||||
|
'unused-node' => $unusedEndpoint,
|
||||||
|
],
|
||||||
|
randomize: false,
|
||||||
|
);
|
||||||
|
$protocolClient = new Client($protocolPool, $crypto, $credentials);
|
||||||
|
$protocolRejected = false;
|
||||||
|
try {
|
||||||
|
$protocolClient->blockHeight();
|
||||||
|
} catch (ProtocolException) {
|
||||||
|
$protocolRejected = true;
|
||||||
|
}
|
||||||
|
expect($protocolRejected, 'Endpoint pool swallowed a protocol error.');
|
||||||
|
expect(
|
||||||
|
$unusedEndpoint->calls === 0,
|
||||||
|
'Endpoint pool retried a request after a protocol error.',
|
||||||
|
);
|
||||||
|
|
||||||
|
$allFailedPool = new EndpointPoolTransport(
|
||||||
|
[
|
||||||
|
'offline-one' => new TestTransport(
|
||||||
|
failure: new TransportException('First endpoint failed.'),
|
||||||
|
),
|
||||||
|
'offline-two' => new TestTransport(
|
||||||
|
failure: new TransportException('Second endpoint failed.'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
randomize: false,
|
||||||
|
);
|
||||||
|
$allFailedClient = new Client($allFailedPool, $crypto, $credentials);
|
||||||
|
$allFailedMessage = '';
|
||||||
|
try {
|
||||||
|
$allFailedClient->blockHeight();
|
||||||
|
} catch (TransportException $error) {
|
||||||
|
$allFailedMessage = $error->getMessage();
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
str_contains($allFailedMessage, 'offline-one')
|
||||||
|
&& str_contains($allFailedMessage, 'offline-two'),
|
||||||
|
'Endpoint pool failure did not identify every failed endpoint.',
|
||||||
|
);
|
||||||
|
|
||||||
|
$emptyPoolRejected = false;
|
||||||
|
try {
|
||||||
|
new EndpointPoolTransport([]);
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
$emptyPoolRejected = true;
|
||||||
|
}
|
||||||
|
expect($emptyPoolRejected, 'Endpoint pool accepted an empty endpoint list.');
|
||||||
|
|
||||||
echo "All protocol tests passed.\n";
|
echo "All protocol tests passed.\n";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue