Skip to content

Commit

Permalink
168 nodes online 3 (#221)
Browse files Browse the repository at this point in the history
* docs: test commit

* chore: test commit revert

* feat: implement first version of node health check

* docs: added doco to two methods

* fix: seperated into integration & unit tests

* feat: added additional unit tests with mocks

* chore: updated yarn.lock

* chore: updated yarn.lock

* fix: use the predefined Thor Solo node address

* test: get code coverage to 100%

* fix: renamed constant

* docs: added missing doc

* fix: moved constant thorest/blocks.ts

* fix: referenced BlockDetail instead of local interface

* refactor: from a utility method to a class and integrate with ThorClient/HTTPClient

* refactor: change 2 variable types to reduce visibility in the class

* docs: improve comments

* fix: expect specific error type

* fix: use specific error type for invlaid block returned

* fix: re-organised unit and integration tests

* fix: regenerated yarn.lock using node version 18.16.0

* fix: rename node into 'nodes' and some minors

* fix: same work for block and blocks

* fix: use blocks client in order to not repeat

* fix: remove redundant endpoint form thorest.ts to

* fix: remove redundant cose and move NODE_HEALTHCHECK_TOLERANCE_IN_SECONDS in constants

* fix: add data in error

---------

Co-authored-by: rodolfopietro97 <[email protected]>
  • Loading branch information
bgaughran and rodolfopietro97 authored Nov 14, 2023
1 parent dd88b92 commit d6470ca
Show file tree
Hide file tree
Showing 11 changed files with 339 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { type BlockDetail } from './types';
* The `BlockClient` class provides methods to interact with block-related endpoints
* of the VechainThor blockchain. It allows fetching details of a specific blockchain block.
*/
class BlockClient {
class BlocksClient {
/**
* Initializes a new instance of the `BlockClient` class.
* @param httpClient - The HTTP client instance used for making HTTP requests.
Expand Down Expand Up @@ -61,4 +61,4 @@ class BlockClient {
}
}

export { BlockClient };
export { BlocksClient };
2 changes: 1 addition & 1 deletion packages/network/src/client/thor/blocks/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export * from './types.d';
export * from './block-client';
export * from './blocks-client';
1 change: 1 addition & 0 deletions packages/network/src/client/thor/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './accounts';
export * from './blocks';
export * from './logs';
export * from './nodes';
export * from './transactions';
export * from './thor-client';
1 change: 1 addition & 0 deletions packages/network/src/client/thor/nodes/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './nodes-client';
96 changes: 96 additions & 0 deletions packages/network/src/client/thor/nodes/nodes-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { type HttpClient } from '../../http';
import { type BlockDetail, BlocksClient } from '../blocks';
import { buildError, DATA } from '@vechain-sdk/errors';
import { NODE_HEALTHCHECK_TOLERANCE_IN_SECONDS } from '../../../utils';

/**
* Provides utility method for checking the health of a node.
*/
class NodesClient {
/**
* Internal blocks client instance used for interacting with block-related endpoints.
*/
private readonly blocksClient: BlocksClient;

/**
* Initializes a new instance of the `NodeClient` class.
* @param httpClient - The HTTP client instance used for making HTTP requests.
*/
constructor(readonly httpClient: HttpClient) {
this.blocksClient = new BlocksClient(httpClient);
}

/**
* Checks the health of a node using the following algorithm:
* 1. Make an HTTP GET request to retrieve the last block timestamp.
* 2. Calculates the difference between the current time and the last block timestamp.
* 3. If the difference is less than the tolerance, the node is healthy.
* Note, we could also check '/node/network/peers since' but the difficulty with this approach is
* if you consider a scenario where the node is connected to 20+ peers, which is healthy, and it receives the new blocks as expected.
* But what if the node's disk is full, and it's not writing the new blocks to its database? In this case the node is off-sync even
* though it's technically alive and connected
* @returns A boolean indicating whether the node is healthy.
* @throws {InvalidDataTypeError} - if the timestamp key does not exist in the response from the API call to the node
* @throws {InvalidDataTypeError} - if the timestamp key exists in the response from the API call to the node but the value is not a number
* @throws {InvalidDataTypeError} - if the response from the API call to the node is not an object
* @throws {InvalidDataTypeError} - if the response from the API call to the node is null or undefined
*/
public async isHealthy(): Promise<boolean> {
/**
* @internal
* Perform an HTTP GET request using the SimpleNet instance to get the latest block
*/
const response = await this.blocksClient.getBestBlock();

/**
* timestamp from the last block
* @internal
*/
const lastBlockTimestamp: number = this.getTimestampFromBlock(response);

/**
* seconds elapsed since the timestamp of the last block
* @internal
*/
const secondsSinceLastBlock =
Math.floor(Date.now() / 1000) - lastBlockTimestamp;

return (
Math.abs(secondsSinceLastBlock) <
NODE_HEALTHCHECK_TOLERANCE_IN_SECONDS
);
}

/**
* Extracts the timestamp from the block
* @remarks
* This function throws an error if the timestamp key does not exist in the response from the API call to the node
* @param response the response from the API call to the node
* @returns the timestamp from the block
* @throws {InvalidDataTypeError} - if the timestamp key does not exist in the response from the API call to the node
* @throws {InvalidDataTypeError} - if the timestamp key exists in the response from the API call to the node but the value is not a number
* @throws {InvalidDataTypeError} - if the response from the API call to the node is not an object
* @throws {InvalidDataTypeError} - if the response from the API call to the node is null or undefined
*/
private readonly getTimestampFromBlock = (
response: BlockDetail | null
): number => {
if (
response === null ||
response === undefined ||
typeof response !== 'object' ||
!('timestamp' in response) ||
typeof response.timestamp !== 'number'
) {
throw buildError(
DATA.INVALID_DATA_TYPE,
'Invalid block format returned from node. The block must be an object with a timestamp key present of type number',
{ response }
);
}

return response.timestamp;
};
}

export { NodesClient };
13 changes: 10 additions & 3 deletions packages/network/src/client/thor/thor-client.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { type HttpClient } from '../http';
import { AccountClient } from './accounts';
import { BlockClient } from './blocks';
import { BlocksClient } from './blocks';
import { LogsClient } from './logs';
import { TransactionClient } from './transactions';
import { NodesClient } from './nodes';

/**
* The `ThorClient` class serves as an interface to interact with the Vechain Thor blockchain.
Expand All @@ -17,7 +18,7 @@ class ThorClient {
/**
* The `BlockClient` instance used for interacting with block-related endpoints.
*/
public readonly blocks: BlockClient;
public readonly blocks: BlocksClient;

/**
* The `LogsClient` instance used for interacting with log-related endpoints.
Expand All @@ -29,14 +30,20 @@ class ThorClient {
*/
public readonly transactions: TransactionClient;

/**
* The `NodeClient` instance used for interacting with node-related endpoints.
*/
public readonly nodes: NodesClient;

/**
* Constructs a new `ThorClient` instance with a given HTTP client.
* @param httpClient - The HTTP client instance used for making network requests.
*/
constructor(protected readonly httpClient: HttpClient) {
this.accounts = new AccountClient(httpClient);
this.blocks = new BlockClient(httpClient);
this.blocks = new BlocksClient(httpClient);
this.logs = new LogsClient(httpClient);
this.nodes = new NodesClient(httpClient);
this.transactions = new TransactionClient(httpClient);
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/network/src/utils/const/client/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './http-client';
export * from './nodes';
9 changes: 9 additions & 0 deletions packages/network/src/utils/const/client/nodes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Node healtcheck Tolerance in seconds.
* @example When set to 30, it means that we consider a node healthy even when it's off-sync by roughly 3 blocks.
*
* @public
*/
const NODE_HEALTHCHECK_TOLERANCE_IN_SECONDS = 30;

export { NODE_HEALTHCHECK_TOLERANCE_IN_SECONDS };
91 changes: 91 additions & 0 deletions packages/network/tests/client/thor-client/node/fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* @internal
* Block with a timestamp much older than the current time
*/
const blockWithOldTimeStamp = {
number: 16935885,
id: '0x01026bcde286e4c5b55507477edc666bb79b41ea97b6e78d65726fe557131533',
size: 361,
parentID:
'0x01026bcc5214fdc936b9afd15460479dfe35972219f78f322e23cc8184a035ab',
timestamp: 16993933000,
gasLimit: 30000000,
beneficiary: '0xb4094c25f86d628fdd571afc4077f0d0196afb48',
gasUsed: 0,
totalScore: 131653862,
txsRoot:
'0x45b0cfc220ceec5b7c1c62c4d4193d38e4eba48e8815729ce75f9c0ab0e4c1c0',
txsFeatures: 1,
stateRoot:
'0x0b43423ced22d182d73728e47fef395169bff38c725dfdb84589e3cedfad2db2',
receiptsRoot:
'0x45b0cfc220ceec5b7c1c62c4d4193d38e4eba48e8815729ce75f9c0ab0e4c1c0',
com: true,
signer: '0xd6fab81fd54b989655b42d51b0344ddcb5007a5a',
isTrunk: true,
isFinalized: false,
transactions: []
};

/**
* @internal
* Block with a missing timestamp
*/
const blockWithMissingTimeStamp = {
number: 16935885,
id: '0x01026bcde286e4c5b55507477edc666bb79b41ea97b6e78d65726fe557131533',
size: 361,
parentID:
'0x01026bcc5214fdc936b9afd15460479dfe35972219f78f322e23cc8184a035ab',
gasLimit: 30000000,
beneficiary: '0xb4094c25f86d628fdd571afc4077f0d0196afb48',
gasUsed: 0,
totalScore: 131653862,
txsRoot:
'0x45b0cfc220ceec5b7c1c62c4d4193d38e4eba48e8815729ce75f9c0ab0e4c1c0',
txsFeatures: 1,
stateRoot:
'0x0b43423ced22d182d73728e47fef395169bff38c725dfdb84589e3cedfad2db2',
receiptsRoot:
'0x45b0cfc220ceec5b7c1c62c4d4193d38e4eba48e8815729ce75f9c0ab0e4c1c0',
com: true,
signer: '0xd6fab81fd54b989655b42d51b0344ddcb5007a5a',
isTrunk: true,
isFinalized: false,
transactions: []
};

/**
* @internal
* Block with an invalid timestamp format
*/
const blockWithInvalidTimeStampFormat = {
number: 16935885,
id: '0x01026bcde286e4c5b55507477edc666bb79b41ea97b6e78d65726fe557131533',
size: 361,
parentID:
'0x01026bcc5214fdc936b9afd15460479dfe35972219f78f322e23cc8184a035ab',
timestamp: 'bad timestamp type',
gasLimit: 30000000,
beneficiary: '0xb4094c25f86d628fdd571afc4077f0d0196afb48',
gasUsed: 0,
totalScore: 131653862,
txsRoot:
'0x45b0cfc220ceec5b7c1c62c4d4193d38e4eba48e8815729ce75f9c0ab0e4c1c0',
txsFeatures: 1,
stateRoot:
'0x0b43423ced22d182d73728e47fef395169bff38c725dfdb84589e3cedfad2db2',
receiptsRoot:
'0x45b0cfc220ceec5b7c1c62c4d4193d38e4eba48e8815729ce75f9c0ab0e4c1c0',
com: true,
signer: '0xd6fab81fd54b989655b42d51b0344ddcb5007a5a',
isTrunk: true,
isFinalized: false,
transactions: []
};

export {
blockWithOldTimeStamp,
blockWithMissingTimeStamp,
blockWithInvalidTimeStampFormat
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, test } from '@jest/globals';
import { thorSoloClient } from '../../../fixture';
import { HttpClient, ThorClient } from '../../../../src';
import { HTTPClientError } from '@vechain-sdk/errors';

/**
* Node integration tests
* @group integration/node
*/
describe('Integration tests to check the Node health check for different scenarios', () => {
test('valid URL but inaccessible VeChain node', async () => {
/**
* client required to access a node
* @internal
*/
const thorClient = new ThorClient(new HttpClient('www.google.ie'));
await expect(thorClient.nodes.isHealthy()).rejects.toThrowError(
HTTPClientError
);
});

test('invalid URL', async () => {
/**
* client required to access a node
* @internal
*/
const thorClient = new ThorClient(new HttpClient('INVALID_URL'));
await expect(thorClient.nodes.isHealthy()).rejects.toThrowError(
HTTPClientError
);
});

test('valid and available synchronized node', async () => {
const healtyNode = await thorSoloClient.nodes.isHealthy();
expect(healtyNode).toBe(true);
});

test('null or empty URL or blank URL', async () => {
let thorClient = new ThorClient(new HttpClient(''));
await expect(thorClient.nodes.isHealthy()).rejects.toThrowError(
HTTPClientError
);
thorClient = new ThorClient(new HttpClient(' '));
await expect(thorClient.nodes.isHealthy()).rejects.toThrowError(
HTTPClientError
);
});
});
Loading

1 comment on commit d6470ca

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test Coverage

Summary

Lines Statements Branches Functions
Coverage: 100%
100% (1122/1122) 100% (317/317) 100% (203/203)
Title Tests Skipped Failures Errors Time
core 303 0 💤 0 ❌ 0 🔥 1m 28s ⏱️
network 62 0 💤 0 ❌ 0 🔥 44.932s ⏱️
errors 19 0 💤 0 ❌ 0 🔥 9.771s ⏱️

Please sign in to comment.