diff --git a/block-node/app/build.gradle.kts b/block-node/app/build.gradle.kts index 1220c8332..2a1c85f6c 100644 --- a/block-node/app/build.gradle.kts +++ b/block-node/app/build.gradle.kts @@ -63,6 +63,7 @@ mainModuleInfo { runtimeOnly("org.hiero.block.node.blocks.files.historic") runtimeOnly("org.hiero.block.node.blocks.files.recent") runtimeOnly("org.hiero.block.node.access.service") + runtimeOnly("org.hiero.block.node.server.status") } testModuleInfo { diff --git a/block-node/server-status/build.gradle.kts b/block-node/server-status/build.gradle.kts new file mode 100644 index 000000000..4c65848c0 --- /dev/null +++ b/block-node/server-status/build.gradle.kts @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +plugins { id("org.hiero.gradle.module.library") } + +description = "Hiero Block Node - Block Node Server Status API Module" + +// Remove the following line to enable all 'javac' lint checks that we have turned on by default +// and then fix the reported issues. +tasks.withType().configureEach { options.compilerArgs.add("-Xlint:-exports") } + +mainModuleInfo { + runtimeOnly("com.swirlds.config.impl") + runtimeOnly("io.helidon.logging.jul") + runtimeOnly("com.hedera.pbj.grpc.helidon.config") +} + +testModuleInfo { + requires("org.junit.jupiter.api") + requires("org.hiero.block.node.app.test.fixtures") +} diff --git a/block-node/server-status/src/main/java/module-info.java b/block-node/server-status/src/main/java/module-info.java new file mode 100644 index 000000000..69357387a --- /dev/null +++ b/block-node/server-status/src/main/java/module-info.java @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 +import org.hiero.block.node.server.status.ServerStatusServicePlugin; + +module org.hiero.block.node.server.status { + uses com.swirlds.config.api.spi.ConfigurationBuilderFactory; + + requires transitive com.hedera.pbj.runtime; + requires transitive org.hiero.block.node.spi; + requires com.swirlds.metrics.api; + requires org.hiero.block.protobuf; + requires com.github.spotbugs.annotations; + + provides org.hiero.block.node.spi.BlockNodePlugin with + ServerStatusServicePlugin; +} diff --git a/block-node/server-status/src/main/java/org/hiero/block/node/server/status/ServerStatusServicePlugin.java b/block-node/server-status/src/main/java/org/hiero/block/node/server/status/ServerStatusServicePlugin.java new file mode 100644 index 000000000..fa2f70626 --- /dev/null +++ b/block-node/server-status/src/main/java/org/hiero/block/node/server/status/ServerStatusServicePlugin.java @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: Apache-2.0 +package org.hiero.block.node.server.status; + +import static java.lang.System.Logger.Level.DEBUG; +import static java.util.Objects.requireNonNull; + +import com.hedera.pbj.runtime.grpc.GrpcException; +import com.hedera.pbj.runtime.grpc.Pipeline; +import com.hedera.pbj.runtime.grpc.Pipelines; +import com.hedera.pbj.runtime.grpc.ServiceInterface; +import com.hedera.pbj.runtime.io.buffer.Bytes; +import com.swirlds.metrics.api.Counter; +import edu.umd.cs.findbugs.annotations.NonNull; +import java.util.Arrays; +import java.util.List; +import org.hiero.block.api.ServerStatusRequest; +import org.hiero.block.api.ServerStatusResponse; +import org.hiero.block.api.protoc.BlockNodeServiceGrpc; +import org.hiero.block.node.spi.BlockNodeContext; +import org.hiero.block.node.spi.BlockNodePlugin; +import org.hiero.block.node.spi.ServiceBuilder; +import org.hiero.block.node.spi.historicalblocks.HistoricalBlockFacility; + +/** + * Plugin that implements the BlockNodeService and provides the 'serverStatus' RPC. + */ +public class ServerStatusServicePlugin implements BlockNodePlugin, ServiceInterface { + /** The logger for this class. */ + private final System.Logger LOGGER = System.getLogger(getClass().getName()); + /** The block provider */ + private HistoricalBlockFacility blockProvider; + /** The block node context, used to provide access to facilities */ + private BlockNodeContext context; + /** Counter for the number of requests */ + private Counter requestCounter; + + /** + * Handle a request for server status + * + * @param request the request containing the available blocks, state snapshot status and software version + * @return the response containing the block or an error status + */ + private ServerStatusResponse handleServiceStatusRequest(@NonNull final ServerStatusRequest request) { + requestCounter.increment(); + + final ServerStatusResponse.Builder serverStatusResponse = ServerStatusResponse.newBuilder(); + final long firstAvailableBlock = blockProvider.availableBlocks().min(); + final long lastAvailableBlock = blockProvider.availableBlocks().max(); + + // TODO(#579) Should get from state config or status, which would be provided by the context from responsible + // facility + boolean onlyLatestState = false; + + // TODO(#1139) Should get construct a block node version object from application config, which would be provided + // by + // the context from responsible facility + + return serverStatusResponse + .firstAvailableBlock(firstAvailableBlock) + .lastAvailableBlock(lastAvailableBlock) + .onlyLatestState(onlyLatestState) + .build(); + } + + // ==== BlockNodePlugin Methods ==================================================================================== + @Override + public String name() { + return "ServerStatusServicePlugin"; + } + + @Override + public void init(@NonNull final BlockNodeContext context, @NonNull final ServiceBuilder serviceBuilder) { + requireNonNull(serviceBuilder); + this.context = requireNonNull(context); + this.blockProvider = requireNonNull(context.historicalBlockProvider()); + + // Create the metrics + requestCounter = context.metrics() + .getOrCreate(new Counter.Config(METRICS_CATEGORY, "server-status-requests") + .withDescription("Number of server status requests")); + + // Register this service + serviceBuilder.registerGrpcService(this); + } + // ==== ServiceInterface Methods =================================================================================== + /** + * BlockNodeService methods define the gRPC methods available on the BlockNodeService. + */ + enum BlockNodeServiceMethod implements Method { + /** + * The serverStatus method retrieves the status of this Block Node. + */ + serverStatus + } + + /** + * {@inheritDoc} + */ + @NonNull + @Override + public String serviceName() { + String[] parts = fullName().split("\\."); + return parts[parts.length - 1]; + } + + /** + * {@inheritDoc} + */ + @NonNull + @Override + public String fullName() { + return BlockNodeServiceGrpc.SERVICE_NAME; + } + + /** + * {@inheritDoc} + */ + @NonNull + @Override + public List methods() { + return Arrays.asList(BlockNodeServiceMethod.values()); + } + + /** + * {@inheritDoc} + * + * This is called each time a new request is received. + */ + @NonNull + @Override + public Pipeline open( + @NonNull Method method, @NonNull RequestOptions requestOptions, @NonNull Pipeline pipeline) + throws GrpcException { + final BlockNodeServiceMethod blockNodeServiceMethod = (BlockNodeServiceMethod) method; + return switch (blockNodeServiceMethod) { + case serverStatus: + yield Pipelines.unary() + .mapRequest(ServerStatusRequest.PROTOBUF::parse) + .method(this::handleServiceStatusRequest) + .mapResponse(ServerStatusResponse.PROTOBUF::toBytes) + .respondTo(pipeline) + .build(); + }; + } +} diff --git a/block-node/server-status/src/test/java/org/hiero/block/node/server/status/ServerStatusServicePluginTest.java b/block-node/server-status/src/test/java/org/hiero/block/node/server/status/ServerStatusServicePluginTest.java new file mode 100644 index 000000000..3d91ff796 --- /dev/null +++ b/block-node/server-status/src/test/java/org/hiero/block/node/server/status/ServerStatusServicePluginTest.java @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: Apache-2.0 +package org.hiero.block.node.server.status; + +import static org.hiero.block.node.app.fixtures.TestUtils.enableDebugLogging; +import static org.hiero.block.node.app.fixtures.blocks.BlockItemUtils.toBlockItemsUnparsed; +import static org.hiero.block.node.app.fixtures.blocks.SimpleTestBlockItemBuilder.createNumberOfVerySimpleBlocks; +import static org.hiero.block.node.server.status.ServerStatusServicePlugin.BlockNodeServiceMethod.serverStatus; +import static org.hiero.block.node.spi.BlockNodePlugin.UNKNOWN_BLOCK_NUMBER; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import com.hedera.hapi.block.stream.BlockItem; +import com.hedera.pbj.runtime.ParseException; +import com.hedera.pbj.runtime.grpc.ServiceInterface; +import java.util.List; +import org.hiero.block.api.ServerStatusRequest; +import org.hiero.block.api.ServerStatusResponse; +import org.hiero.block.node.app.fixtures.plugintest.GrpcPluginTestBase; +import org.hiero.block.node.app.fixtures.plugintest.SimpleInMemoryHistoricalBlockFacility; +import org.hiero.block.node.spi.blockmessaging.BlockItems; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Tests for the ServerStatusServicePlugin class. + * Validates the functionality of the server status service and its responses + * under different conditions. + */ +public class ServerStatusServicePluginTest extends GrpcPluginTestBase { + + public ServerStatusServicePluginTest() { + super(); + start(new ServerStatusServicePlugin(), serverStatus, new SimpleInMemoryHistoricalBlockFacility()); + } + + /** + * Enable debug logging for each test. + */ + @BeforeEach + void setup() { + enableDebugLogging(); + } + + /** + * Verifies that the service interface correctly registers and exposes + * the server status method. + */ + @Test + @DisplayName("Should return correct method for ServerStatusServicePlugin") + void shouldReturnCorrectMethod() { + assertNotNull(serviceInterface); + List methods = serviceInterface.methods(); + assertNotNull(methods); + assertEquals(1, methods.size()); + assertEquals(serverStatus, methods.getFirst()); + } + + /** + * Tests that the server status response is valid when no blocks are available. + * Verifies the first and last available block numbers and other response properties. + * + * @throws ParseException if there is an error parsing the response + */ + @Test + @DisplayName("Should return valid Server Status when no blocks available") + void shouldReturnValidServerStatus() throws ParseException { + // sendBlocks(5); + final ServerStatusRequest request = ServerStatusRequest.newBuilder().build(); + toPluginPipe.onNext(ServerStatusRequest.PROTOBUF.toBytes(request)); + assertEquals(1, fromPluginBytes.size()); + + final ServerStatusResponse response = ServerStatusResponse.PROTOBUF.parse(fromPluginBytes.getFirst()); + + assertNotNull(response); + assertEquals(0, response.firstAvailableBlock()); + assertEquals(0, response.lastAvailableBlock()); + assertFalse(response.onlyLatestState()); + + // TODO() Remove when block node version information is implemented. + assertFalse(response.hasVersionInformation()); + } + + /** + * Tests the server status response after adding a new batch of blocks. + * Verifies that the first and last available block numbers are correctly updated. + * + * @throws ParseException if there is an error parsing the response + */ + @Test + @DisplayName("Should return valid Server Status, after new batch of blocks") + void shouldReturnValidServerStatusForNewBlockBatch() throws ParseException { + final int blocks = 5; + sendBlocks(blocks); + final ServerStatusRequest request = ServerStatusRequest.newBuilder().build(); + toPluginPipe.onNext(ServerStatusRequest.PROTOBUF.toBytes(request)); + assertEquals(1, fromPluginBytes.size()); + + final ServerStatusResponse response = ServerStatusResponse.PROTOBUF.parse(fromPluginBytes.getLast()); + + assertNotNull(response); + assertEquals(0, response.firstAvailableBlock()); + assertEquals(blocks - 1, response.lastAvailableBlock()); + assertFalse(response.onlyLatestState()); + + // TODO() Remove when block node version information is implemented. + assertFalse(response.hasVersionInformation()); + } + + /** + * Helper method to send a specified number of test blocks to the block messaging system. + * + * @param numberOfBlocks the number of test blocks to create and send + */ + private void sendBlocks(int numberOfBlocks) { + BlockItem[] blockItems = createNumberOfVerySimpleBlocks(numberOfBlocks); + // Send some blocks + for (BlockItem blockItem : blockItems) { + long blockNumber = + blockItem.hasBlockHeader() ? blockItem.blockHeader().number() : UNKNOWN_BLOCK_NUMBER; + blockMessaging.sendBlockItems(new BlockItems(toBlockItemsUnparsed(blockItem), blockNumber)); + } + } +} diff --git a/hiero-dependency-versions/build.gradle.kts b/hiero-dependency-versions/build.gradle.kts index 4f00525df..516479c39 100644 --- a/hiero-dependency-versions/build.gradle.kts +++ b/hiero-dependency-versions/build.gradle.kts @@ -14,7 +14,7 @@ dependencies.constraints { val helidonVersion = "4.2.2" // When Upgrading pbjVersion, also need to update pbjCompiler version on // protobuf/build.gradle.kts - val pbjVersion = "0.11.3" + val pbjVersion = "0.11.4" val protobufVersion = "4.30.2" val swirldsVersion = "0.61.3" val mockitoVersion = "5.17.0" diff --git a/settings.gradle.kts b/settings.gradle.kts index 387437d22..ae92587e2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -28,5 +28,6 @@ javaModules { module("block-providers/files.historic") { artifact = "block-node-blocks-file-historic" } module("block-providers/files.recent") { artifact = "block-node-blocks-file-recent" } module("block-access") { artifact = "block-access-service" } + module("server-status") { artifact = "block-node-server-status" } } } diff --git a/suites/src/main/java/org/hiero/block/suites/BaseSuite.java b/suites/src/main/java/org/hiero/block/suites/BaseSuite.java index 8eb449d8b..84b0bc618 100644 --- a/suites/src/main/java/org/hiero/block/suites/BaseSuite.java +++ b/suites/src/main/java/org/hiero/block/suites/BaseSuite.java @@ -8,6 +8,7 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import org.hiero.block.api.protoc.BlockAccessServiceGrpc; +import org.hiero.block.api.protoc.BlockNodeServiceGrpc; import org.hiero.block.simulator.BlockStreamSimulatorApp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -47,6 +48,9 @@ public abstract class BaseSuite { /** gRPC client stub for BlockAccessService */ protected static BlockAccessServiceGrpc.BlockAccessServiceBlockingStub blockAccessStub; + /** gRPC client stub for BlockNodeService */ + protected static BlockNodeServiceGrpc.BlockNodeServiceBlockingStub blockServiceStub; + /** * Default constructor for the BaseSuite class. * @@ -68,6 +72,7 @@ public void setup() { blockNodeContainer.start(); executorService = new ErrorLoggingExecutor(); blockAccessStub = initializeBlockAccessGrpcClient(); + blockServiceStub = initializeBlockNodeServiceGrpcClient(); } /** @@ -136,6 +141,13 @@ protected static BlockAccessServiceGrpc.BlockAccessServiceBlockingStub initializ return BlockAccessServiceGrpc.newBlockingStub(channel); } + protected static BlockNodeServiceGrpc.BlockNodeServiceBlockingStub initializeBlockNodeServiceGrpcClient() { + final String host = blockNodeContainer.getHost(); + final int port = blockNodePort; + channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext().build(); + return BlockNodeServiceGrpc.newBlockingStub(channel); + } + /** * Starts the block stream simulator in a separate thread. * diff --git a/suites/src/main/java/org/hiero/block/suites/server/status/ServerStatusTestSuites.java b/suites/src/main/java/org/hiero/block/suites/server/status/ServerStatusTestSuites.java new file mode 100644 index 000000000..e4126d734 --- /dev/null +++ b/suites/src/main/java/org/hiero/block/suites/server/status/ServerStatusTestSuites.java @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +package org.hiero.block.suites.server.status; + +import org.hiero.block.suites.server.status.positive.PositiveServerStatusTests; +import org.junit.platform.suite.api.SelectClasses; +import org.junit.platform.suite.api.Suite; + +@Suite +@SelectClasses({PositiveServerStatusTests.class}) +public class ServerStatusTestSuites { + /** + * Default constructor for the {@link ServerStatusTestSuites} class.This constructor is + * empty as it does not need to perform any initialization. + */ + public ServerStatusTestSuites() {} +} diff --git a/suites/src/main/java/org/hiero/block/suites/server/status/positive/PositiveServerStatusTests.java b/suites/src/main/java/org/hiero/block/suites/server/status/positive/PositiveServerStatusTests.java new file mode 100644 index 000000000..2086bf245 --- /dev/null +++ b/suites/src/main/java/org/hiero/block/suites/server/status/positive/PositiveServerStatusTests.java @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 +package org.hiero.block.suites.server.status.positive; + +import static org.hiero.block.suites.utils.BlockSimulatorUtils.createBlockSimulator; +import static org.hiero.block.suites.utils.ServerStatusUtils.requestServerStatus; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import edu.umd.cs.findbugs.annotations.NonNull; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.Future; +import org.hiero.block.api.protoc.ServerStatusResponse; +import org.hiero.block.simulator.BlockStreamSimulatorApp; +import org.hiero.block.suites.BaseSuite; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class PositiveServerStatusTests extends BaseSuite { + private final List> simulators = new ArrayList<>(); + private final List simulatorAppsRef = new ArrayList<>(); + + /** Default constructor for the {@link PositiveServerStatusTests} class. */ + public PositiveServerStatusTests() {} + + @AfterEach + void teardownEnvironment() { + simulatorAppsRef.forEach(simulator -> { + try { + simulator.stop(); + while (simulator.isRunning()) { + Thread.sleep(100); + } + } catch (InterruptedException e) { + // Do nothing, this is not mandatory, we try to shut down cleaner and graceful + } + }); + simulators.forEach(simulator -> simulator.cancel(true)); + simulators.clear(); + } + + @Test + @DisplayName("Should be able to request and receive server status") + public void shouldSuccessfullyRequestServerStatus() throws IOException { + // ===== Get Server Status before starting publisher ======================================== + final ServerStatusResponse serverStatusBefore = requestServerStatus(blockServiceStub); + + assertNotNull(serverStatusBefore); + assertEquals(-1L, serverStatusBefore.getFirstAvailableBlock()); + assertEquals(-1L, serverStatusBefore.getLastAvailableBlock()); + + final BlockStreamSimulatorApp publisherSimulator = createBlockSimulator(); + simulatorAppsRef.add(publisherSimulator); + + // ===== Start publisher and make sure it's streaming ======================================= + final Future publisherSimulatorThread = startSimulatorInstance(publisherSimulator); + simulators.add(publisherSimulatorThread); + + // ===== Get Server Status after and assert ================================================= + final ServerStatusResponse serverStatusAfter = requestServerStatus(blockServiceStub); + + assertNotNull(serverStatusAfter); + assertTrue(serverStatusAfter.getLastAvailableBlock() > serverStatusBefore.getLastAvailableBlock()); + } + + /** + * Starts a simulator in a thread and make sure that it's running and trying to publish blocks + * + * @param simulator instance with configuration depending on the test + * @return a {@link Future} representing the asynchronous execution of the block stream simulator + */ + private Future startSimulatorInstance(@NonNull final BlockStreamSimulatorApp simulator) { + Objects.requireNonNull(simulator); + final int statusesRequired = 5; // we wait for at least 5 statuses, to avoid flakiness + + final Future simulatorThread = startSimulatorInThread(simulator); + simulators.add(simulatorThread); + String simulatorStatus = null; + while (simulator.getStreamStatus().lastKnownPublisherClientStatuses().size() < statusesRequired) { + if (!simulator.getStreamStatus().lastKnownPublisherClientStatuses().isEmpty()) { + simulatorStatus = simulator + .getStreamStatus() + .lastKnownPublisherClientStatuses() + .getLast(); + } + } + assertNotNull(simulatorStatus); + assertTrue(simulator.isRunning()); + return simulatorThread; + } +} diff --git a/suites/src/main/java/org/hiero/block/suites/utils/ServerStatusUtils.java b/suites/src/main/java/org/hiero/block/suites/utils/ServerStatusUtils.java new file mode 100644 index 000000000..cbeaaaa9f --- /dev/null +++ b/suites/src/main/java/org/hiero/block/suites/utils/ServerStatusUtils.java @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +package org.hiero.block.suites.utils; + +import edu.umd.cs.findbugs.annotations.NonNull; +import org.hiero.block.api.protoc.BlockNodeServiceGrpc; +import org.hiero.block.api.protoc.ServerStatusRequest; +import org.hiero.block.api.protoc.ServerStatusResponse; + +public final class ServerStatusUtils { + private ServerStatusUtils() { + // Prevent instantiation + } + + public static ServerStatusResponse requestServerStatus( + @NonNull final BlockNodeServiceGrpc.BlockNodeServiceBlockingStub blockNodeServiceStub) { + return blockNodeServiceStub.serverStatus( + ServerStatusRequest.newBuilder().build()); + } +}