diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml new file mode 100644 index 0000000..90ec641 --- /dev/null +++ b/.github/workflows/gradle.yml @@ -0,0 +1,38 @@ +name: Gradle CI + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: 17 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Grant execute permission for Gradle wrapper + run: chmod +x gradlew + + - name: Build with Gradle + run: ./gradlew clean build + + - name: Run tests + run: ./gradlew clean test diff --git a/DESM_doc.pdf b/DESM_doc.pdf new file mode 100644 index 0000000..9c8ac3e Binary files /dev/null and b/DESM_doc.pdf differ diff --git a/DPS_Project_2025.pdf b/DPS_Project_2025.pdf new file mode 100644 index 0000000..70b5ba0 Binary files /dev/null and b/DPS_Project_2025.pdf differ diff --git a/README.md b/README.md index 8e4e0e2..3c1b779 100644 --- a/README.md +++ b/README.md @@ -1 +1,108 @@ -## DESM - Distributed Energy Supply Management \ No newline at end of file +# DESM - Distributed Energy System Management + +This is a master's degree project that was developed for the Distributed and Pervasive Systems course (A.Y 2024/25). + +## Project Overview + +The DESM (Distributed Energy Supply Management) project simulates a renewable energy supply networkRepository. A renewable energy provider generates power from sources like wind, hydro, and solar. When renewable energy alone can't meet demand, the provider relies on a networkRepository of thermal power plants to cover the shortfall. + +These thermal plants compete to fulfill energy requests, with the lowest bidder winning the assignment. An administration server manages the registration/removement of thermal plants and collects environmental data, such as pollutant emissions, from their sensors. This data is periodically sent to the server, which an administration client can query for statistics. + +![DESM Architecture](./assets/system-architecture.png) + +## System Architecture + +For sake of simplicity and ease of development, this project is structured as a Gradle multi-module project, rather than a full-fledged microservices architecture, even though it comprises distinct logical services. +The main goal of the project is focused on inter-process communication and application of algorithms for distributed systems. + +### Renewable Energy Provider + +This entity represents the source of energy demand in the system. It initiates energy production requests when renewable sources are insufficient, transmitting this data to the Network Thermal Power Plants through MQTT. + +### Power Plant Network + +This is the core distributed component, a networkRepository of interconnected **Thermal Power Plant Peers** operating in a ring topology. Each peer is an independent node responsible for: + +* **Network Management**: Manage the ring structure, maintaining an up-to-date local view of the ring topology. +* **Election Manager**: Manage the elections for the incoming energy request that have to be fulfilled using the adapted Chang-Roberts algorithm. +* **Pollution Monitoring**: Collecting CO2 emission data and periodically transmitting this data to the Administration Server through MQTT, processed using a sliding window mechanism. + +### Administration Server +A REST server that dynamically adds/removes power plants to DESM and allows the administration client to see the currently active plants in the networkRepository and to compute statistics about pollution levels. + +### Administration Client + +A client cli that allows querying the administration server to obtain information about the currently active thermal power plants in the networkRepository and their emissions. + +## Setup and Run + +To set up and run the DESM project, follow these steps: + +### Prerequisites + + * [**Java Development Kit**](https://www.oracle.com/it/java/technologies/downloads/): Version 17 or higher. + * [**MQTT Broker**](https://mosquitto.org/download/): An MQTT broker (e.g. Mosquitto, HiveMQ Community Edition) must be running and accessible. The system defaults to `tcp://localhost:1883`. + +### Getting Started + +1. **Clone the Repository**: + First, clone the project repository to your local machine: + + ```bash + git clone https://github.com/Luca-02/desm.git + ``` + +2. **Clean and Build**: + Navigate to the root directory of the cloned repository and perform a clean build for all subprojects: + + ```bash + cd desm + ./gradlew clean build + ``` + +### Running the Components + +Ensure your MQTT broker is running before proceeding. You should start the components in the following order: Administration Server, Administration Client, then the Power Plant Peers, and finally, the Renewable Energy Provider. + +1. **Administration Server**: + Navigate to the `desm-server` directory and run the server application: + + ```bash + cd .\desm-server\ + ../gradlew run --console=plain + ``` + +2. **Administration Client**: + Navigate to the `desm-client` directory, and run the client application to interact with the Administration Server: + + ```bash + cd .\desm-client\ + ../gradlew run --console=plain + ``` + +2. **Power Plant Peers**: + Open a new terminal windows for each power plant peer you want to run. Navigate to the `desm-networkRepository` directory and run the application. Each instance will represent a single power plant: + + ```bash + cd .\desm-networkRepository\ + ../gradlew run --console=plain + ``` + +4. **Renewable Energy Provider**: + Navigate to the `desm-provider` directory and run the renewable energy provider application to generate energy requests: + + ```bash + cd .\desm-provider\ + ../gradlew run --console=plain + ``` + +## Technologies Used + +* **Java**: Core programming language for all components. +* **Gradle**: For project build automation. +* **Eclipse Paho MQTT Client**: For MQTT communication. +* **gRPC**: For RPC communication between networkRepository peers. +* **Spring Boot**: Framework for developing the Administration Server. +* **RESTful APIs**: For client-server communication. +* **Gson**: For JSON serialization/deserialization across components. +* **JUnit 5**: For unit and integration testing. diff --git a/assets/system-architecture.png b/assets/system-architecture.png new file mode 100644 index 0000000..b35b123 Binary files /dev/null and b/assets/system-architecture.png differ diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..989a076 --- /dev/null +++ b/build.gradle @@ -0,0 +1,48 @@ +allprojects { + group 'org.example' + version '1.0-SNAPSHOT' +} + +subprojects { + apply plugin: 'java' + apply plugin: 'application' + + java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } + } + + dependencies { + if (project.name != 'desm-core') { + implementation project(':desm-core') + } + + // Testing + testImplementation platform('org.junit:junit-bom:5.10.0') + testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'org.mockito:mockito-core:5.17.0' + testImplementation 'org.mockito:mockito-junit-jupiter:5.17.0' + testImplementation 'org.mockito:mockito-inline:5.2.0' + + // Lombok + compileOnly 'org.projectlombok:lombok:1.18.38' + annotationProcessor 'org.projectlombok:lombok:1.18.38' + testCompileOnly 'org.projectlombok:lombok:1.18.38' + testAnnotationProcessor 'org.projectlombok:lombok:1.18.38' + + // Gson + implementation 'com.google.code.gson:gson:2.13.1' + + // MQTT + implementation 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5' + } + + repositories { + mavenCentral() + } + + test { + useJUnitPlatform() + } +} diff --git a/desm-client/build.gradle b/desm-client/build.gradle new file mode 100644 index 0000000..0e92814 --- /dev/null +++ b/desm-client/build.gradle @@ -0,0 +1,7 @@ +application { + mainClassName = "org.example.desm.client.DesmClient" +} + +run { + standardInput = System.in +} \ No newline at end of file diff --git a/desm-client/src/main/java/org/example/desm/client/Client.java b/desm-client/src/main/java/org/example/desm/client/Client.java new file mode 100644 index 0000000..6ab9c01 --- /dev/null +++ b/desm-client/src/main/java/org/example/desm/client/Client.java @@ -0,0 +1,40 @@ +package org.example.desm.client; + +import org.example.desm.client.command.AvailablePowerPlant; +import org.example.desm.client.command.QueryAverageEmission; + +import java.util.Scanner; + +public class Client { + public final Scanner scanner; + private final Menu mainMenu; + + /** + * Constructor for the Client class. + */ + public Client() { + this.scanner = new Scanner(System.in); + this.mainMenu = buildMainMenu(); + } + + /** + * Starts the client, displaying the menu. + */ + public void start() { + mainMenu.show(); + scanner.close(); + } + + /** + * Builds the main menu with available commands. + * + * @return The main menu + */ + private Menu buildMainMenu() { + int i = 0; + Menu mainMenu = new Menu("Main Menu", scanner); + mainMenu.addCommand(new AvailablePowerPlant(++i, "View all power plants", scanner)); + mainMenu.addCommand(new QueryAverageEmission(++i, "Query average emission", scanner)); + return mainMenu; + } +} diff --git a/desm-client/src/main/java/org/example/desm/client/DesmClient.java b/desm-client/src/main/java/org/example/desm/client/DesmClient.java new file mode 100644 index 0000000..2083603 --- /dev/null +++ b/desm-client/src/main/java/org/example/desm/client/DesmClient.java @@ -0,0 +1,7 @@ +package org.example.desm.client; + +public class DesmClient { + public static void main(String[] args) { + new Client().start(); + } +} diff --git a/desm-client/src/main/java/org/example/desm/client/Menu.java b/desm-client/src/main/java/org/example/desm/client/Menu.java new file mode 100644 index 0000000..d345b22 --- /dev/null +++ b/desm-client/src/main/java/org/example/desm/client/Menu.java @@ -0,0 +1,72 @@ +package org.example.desm.client; + +import org.example.desm.client.command.Command; + +import java.util.Map; +import java.util.Scanner; +import java.util.TreeMap; + +/** + * Represents a menu that displays a list of commands and allows the user to select one to execute. + */ +public class Menu { + private final String title; + private final Scanner scanner; + private final Map commands; + + /** + * Constructor for the Menu class. + * + * @param title The title of the menu + * @param scanner The scanner for user input + */ + public Menu(String title, Scanner scanner) { + this.title = title; + this.scanner = scanner; + this.commands = new TreeMap<>(); + } + + /** + * Adds a command to the menu. + * + * @param command The command to add + */ + public void addCommand(Command command) { + commands.put(command.getId(), command); + } + + /** + * Displays the menu and handles user input. + */ + public void show() { + boolean isExiting = false; + while (!isExiting) { + System.out.println("===== " + title + " ====="); + for (Command command : commands.values()) { + System.out.println(command.getId() + ". " + command.getName()); + } + System.out.println("0. Exit"); + + System.out.print("Choose: "); + String input = scanner.nextLine().trim(); + + try { + int choice = Integer.parseInt(input); + if (choice == 0) { + System.out.println("\nGoodbye!"); + isExiting = true; + continue; + } + + Command command = commands.get(choice); + if (command != null) { + command.execute(); + } else { + System.out.println("Invalid option."); + } + } catch (NumberFormatException e) { + System.out.println("Please enter a valid number."); + } + } + } +} diff --git a/desm-client/src/main/java/org/example/desm/client/command/AvailablePowerPlant.java b/desm-client/src/main/java/org/example/desm/client/command/AvailablePowerPlant.java new file mode 100644 index 0000000..ca42f4f --- /dev/null +++ b/desm-client/src/main/java/org/example/desm/client/command/AvailablePowerPlant.java @@ -0,0 +1,37 @@ +package org.example.desm.client.command; + +import org.example.desm.common.ApiClient; +import org.example.desm.common.ResponseErrorExceptions; +import org.example.desm.common.dto.PowerPlant; + +import java.io.IOException; +import java.util.List; +import java.util.Scanner; + +/** + * Custom {@link Command} to retrieve and display all available power plants. + * This command fetches the list of power plants from the API and prints them. + */ +public class AvailablePowerPlant extends Command { + public AvailablePowerPlant(int id, String name, Scanner scanner) { + super(id, name, scanner); + } + + @Override + protected void command() { + try { + List plants = ApiClient.getAllPlants(); + if (plants.isEmpty()) { + System.out.println("No power plants registered."); + } else { + for (PowerPlant plant : plants) { + System.out.println(plant); + } + } + } catch (ResponseErrorExceptions e) { + System.err.println(e.getMessage()); + } catch (IOException | InterruptedException e) { + System.err.println("Unable to retrieve plants!"); + } + } +} diff --git a/desm-client/src/main/java/org/example/desm/client/command/Command.java b/desm-client/src/main/java/org/example/desm/client/command/Command.java new file mode 100644 index 0000000..a761b3e --- /dev/null +++ b/desm-client/src/main/java/org/example/desm/client/command/Command.java @@ -0,0 +1,40 @@ +package org.example.desm.client.command; + +import lombok.Getter; + +import java.util.Scanner; + +/** + * Abstract class that represents a command in the Administration Client. + * Each command has an ID, a name, and a scanner for input. + *

+ * This class is used to implement the Command design pattern, allowing + * for different commands to be executed + */ +@Getter +public abstract class Command { + private final int id; + private final String name; + protected final Scanner scanner; + + public Command(int id, String name, Scanner scanner) { + this.id = id; + this.name = name; + this.scanner = scanner; + } + + /** + * Executes the command. + */ + public void execute() { + command(); + System.out.print("\nPress Enter to continue..."); + scanner.nextLine(); + System.out.println(); + } + + /** + * Abstract method that must be implemented by subclasses to define the command's behavior. + */ + protected abstract void command(); +} diff --git a/desm-client/src/main/java/org/example/desm/client/command/QueryAverageEmission.java b/desm-client/src/main/java/org/example/desm/client/command/QueryAverageEmission.java new file mode 100644 index 0000000..f400ade --- /dev/null +++ b/desm-client/src/main/java/org/example/desm/client/command/QueryAverageEmission.java @@ -0,0 +1,39 @@ +package org.example.desm.client.command; + +import org.example.desm.common.ApiClient; +import org.example.desm.common.ResponseErrorExceptions; +import org.example.desm.common.dto.AverageEmission; + +import java.io.IOException; +import java.util.Scanner; + +/** + * Custom {@link Command} to query the average emissions between two timestamps. + * This command prompts the user for start and end timestamps, retrieves the average emissions from the API, + * and prints the result. + */ +public class QueryAverageEmission extends Command { + public QueryAverageEmission(int id, String name, Scanner scanner) { + super(id, name, scanner); + } + + @Override + protected void command() { + try { + System.out.print("Enter start timestamp: "); + long t1 = Long.parseLong(scanner.nextLine().trim()); + + System.out.print("Enter end timestamp: "); + long t2 = Long.parseLong(scanner.nextLine().trim()); + + AverageEmission result = ApiClient.getAverageEmission(t1, t2); + System.out.println(result.toString()); + } catch (NumberFormatException e) { + System.out.println("Timestamps must be numeric."); + } catch (ResponseErrorExceptions e) { + System.err.println(e.getMessage()); + } catch (IOException | InterruptedException e) { + System.err.println("Failed to query emissions!"); + } + } +} diff --git a/desm-client/src/test/java/org/example/desm/client/ClientTest.java b/desm-client/src/test/java/org/example/desm/client/ClientTest.java new file mode 100644 index 0000000..71096b4 --- /dev/null +++ b/desm-client/src/test/java/org/example/desm/client/ClientTest.java @@ -0,0 +1,66 @@ +package org.example.desm.client; + +import org.example.desm.common.ApiClient; +import org.example.desm.common.dto.AverageEmission; +import org.example.desm.common.dto.PowerPlant; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.ByteArrayInputStream; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.mockStatic; + +@ExtendWith(MockitoExtension.class) +class ClientTest { + @Test + void testAvailablePowerPlantCommand() { + List mockPlants = List.of( + new PowerPlant( "plant-1", "localhost", 8080), + new PowerPlant( "plant-2", "localhost", 8081) + ); + + // Simulate command 1 and then 0 to exit + String commandInput = "1\n\n0\n"; + System.setIn(new ByteArrayInputStream(commandInput.getBytes())); + + try (MockedStatic apiClientMockedStatic = mockStatic(ApiClient.class)) { + apiClientMockedStatic.when(ApiClient::getAllPlants) + .thenReturn(mockPlants); + + new Client().start(); + + apiClientMockedStatic.verify(ApiClient::getAllPlants); + } + } + + @Test + void testQueryAverageEmissionCommand() { + long t1 = 1000L; + long t2 = 2000L; + AverageEmission mockEmission = new AverageEmission(t1, t2, 10.0); + + // Simulate command 2 and then 0 to exit + String commandInput = String.format("2\n%d\n%d\n\n0\n", t1, t2); + System.setIn(new ByteArrayInputStream(commandInput.getBytes())); + + try (MockedStatic apiClientMockedStatic = mockStatic(ApiClient.class)) { + apiClientMockedStatic.when(() -> ApiClient.getAverageEmission(t1, t2)) + .thenReturn(mockEmission); + + new Client().start(); + + apiClientMockedStatic.verify(() -> ApiClient.getAverageEmission(t1, t2)); + } + } + + @Test + void testInvalidCommandInput() { + String commandInput = "x\n0\n"; + System.setIn(new ByteArrayInputStream(commandInput.getBytes())); + assertDoesNotThrow(() -> new Client().start()); + } +} \ No newline at end of file diff --git a/desm-core/build.gradle b/desm-core/build.gradle new file mode 100644 index 0000000..e69de29 diff --git a/desm-core/src/main/java/org/example/desm/common/ApiClient.java b/desm-core/src/main/java/org/example/desm/common/ApiClient.java new file mode 100644 index 0000000..9fa6ecb --- /dev/null +++ b/desm-core/src/main/java/org/example/desm/common/ApiClient.java @@ -0,0 +1,118 @@ +package org.example.desm.common; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.example.desm.common.dto.AverageEmission; +import org.example.desm.common.dto.ErrorResponse; +import org.example.desm.common.dto.PowerPlant; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; + +import static org.example.desm.common.Constant.API_BASE_URL; +import static org.example.desm.common.Constant.ENDPOINT_PLANTS; +import static org.example.desm.common.Constant.ENDPOINT_POLLUTION; + +/** + * A utility class for making HTTP requests to the Administration Server. + * It provides methods to fetch all power plants and to get average emissions + * within a specified time range. + */ +public class ApiClient { + private static final HttpClient client = HttpClient.newHttpClient(); + private static final Gson gson = new Gson(); + + /** + * Fetches all power plants. + * + * @return a list of {@link PowerPlant} objects. + * @throws IOException if an I/O error occurs during the request. + * @throws InterruptedException if the request is interrupted. + * @throws ResponseErrorExceptions if the response indicates an error. + */ + public static List getAllPlants() + throws IOException, InterruptedException, ResponseErrorExceptions { + String uri = API_BASE_URL + ENDPOINT_PLANTS; + + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(uri)) + .GET() + .build(); + + HttpResponse response = client.send(req, HttpResponse.BodyHandlers.ofString()); + validateResponse(response); + + Type listType = new TypeToken>() {}.getType(); + return gson.fromJson(response.body(), listType); + } + + /** + * Registers a new power plant. + * + * @param plant the {@link PowerPlant} object to register. + * @return a list of {@link PowerPlant} objects including the newly registered one. + * @throws IOException if an I/O error occurs during the request. + * @throws InterruptedException if the request is interrupted. + * @throws ResponseErrorExceptions if the response indicates an error. + */ + public static List registerPlant(PowerPlant plant) + throws IOException, InterruptedException, ResponseErrorExceptions { + String uri = API_BASE_URL + ENDPOINT_PLANTS; + + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(uri)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(gson.toJson(plant))) + .build(); + + HttpResponse response = client.send(req, HttpResponse.BodyHandlers.ofString()); + validateResponse(response); + + Type listType = new TypeToken>() {}.getType(); + return gson.fromJson(response.body(), listType); + } + + /** + * Fetches the average emissions for a specified time range. + * + * @param t1 the start time in milliseconds since epoch. + * @param t2 the end time in milliseconds since epoch. + * @return an {@link AverageEmission} object containing the average emission's data. + * @throws IOException if an I/O error occurs during the request. + * @throws InterruptedException if the request is interrupted. + * @throws ResponseErrorExceptions if the response indicates an error. + */ + public static AverageEmission getAverageEmission(long t1, long t2) + throws IOException, InterruptedException, ResponseErrorExceptions { + String uri = API_BASE_URL + ENDPOINT_POLLUTION + "?t1=" + t1 + "&t2=" + t2; + + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(uri)) + .GET() + .build(); + + HttpResponse resp = client.send(req, HttpResponse.BodyHandlers.ofString()); + validateResponse(resp); + + return gson.fromJson(resp.body(), AverageEmission.class); + } + + /** + * Validates the HTTP response status code. + * + * @param response the HttpResponse to validate. + * @throws ResponseErrorExceptions if the response status code indicates an error. + */ + private static void validateResponse(HttpResponse response) throws ResponseErrorExceptions { + int status = response.statusCode(); + if (status < 200 || status >= 300) { + ErrorResponse error = gson.fromJson(response.body(), ErrorResponse.class); + throw new ResponseErrorExceptions(error); + } + } +} diff --git a/desm-core/src/main/java/org/example/desm/common/Constant.java b/desm-core/src/main/java/org/example/desm/common/Constant.java new file mode 100644 index 0000000..86a56f8 --- /dev/null +++ b/desm-core/src/main/java/org/example/desm/common/Constant.java @@ -0,0 +1,14 @@ +package org.example.desm.common; + +public class Constant { + public static final String MQTT_BROKER_ADDRESS = "tcp://localhost:1883"; + public static final String API_BASE_URL = "http://localhost:8080"; + + // MQTT topics + public static final String TOPIC_ENERGY_REQUEST = "energy/request"; + public static final String TOPIC_CO2_MEASUREMENT = "co2/measurement"; + + // API endpoints + public static final String ENDPOINT_PLANTS = "/plants"; + public static final String ENDPOINT_POLLUTION = "/pollution"; +} diff --git a/desm-core/src/main/java/org/example/desm/common/PeriodicMqttMessageSender.java b/desm-core/src/main/java/org/example/desm/common/PeriodicMqttMessageSender.java new file mode 100644 index 0000000..4d445cc --- /dev/null +++ b/desm-core/src/main/java/org/example/desm/common/PeriodicMqttMessageSender.java @@ -0,0 +1,91 @@ +package org.example.desm.common; + +import org.eclipse.paho.client.mqttv3.IMqttClient; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.MqttMessage; + +/** + * Abstract class to periodically perform a specific task. + */ +public abstract class PeriodicMqttMessageSender extends Thread { + protected volatile boolean stopCondition = false; + + protected final IMqttClient client; + protected final String topic; + protected final int qos; + private final long intervalMs; + + /** + * Constructs a {@link PeriodicMqttMessageSender} with custom parameters. + * + * @param client the MQTT client used to publish messages. + * @param topic the topic to publish messages to. + * @param qos the MQTT Quality of Service level. + * @param intervalMs the interval between tasks in milliseconds. + */ + public PeriodicMqttMessageSender(IMqttClient client, String topic, int qos, long intervalMs) { + this.client = client; + this.topic = topic; + this.qos = qos; + this.intervalMs = intervalMs; + } + + /** + * Stops the periodic task. + */ + public void stopMeGently() { + stopCondition = true; + } + + @Override + public void run() { + while (!stopCondition) { + sendMqttMessage(); + sleeping(); + } + } + + /** + * Sleeps for the specified interval in milliseconds. + */ + private void sleeping() { + try { + Thread.sleep(intervalMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + System.out.println("Periodic MQTT message sender interrupted."); + } + } + + /** + * Publishes a message to the MQTT broker. + */ + private void sendMqttMessage() { + String message = getMqttMessage(); + if (message == null || message.isBlank()) { + return; + } + + MqttMessage mqttMessage = new MqttMessage(message.getBytes()); + mqttMessage.setQos(qos); + + System.out.printf( + "[MQTT] Publishing on topic %s message: %s%n", + topic, + message + ); + try { + client.publish(topic, mqttMessage); + } catch (MqttException e) { + System.err.printf("[MQTT] Error publishing message: %s%n", e.getMessage()); + } + } + + /** + * Method to get the MQTT message to be sent. This method should be implemented + * by subclasses. + * + * @return the message as a {@link String}. + */ + protected abstract String getMqttMessage(); +} diff --git a/desm-core/src/main/java/org/example/desm/common/ResponseErrorExceptions.java b/desm-core/src/main/java/org/example/desm/common/ResponseErrorExceptions.java new file mode 100644 index 0000000..4385bc1 --- /dev/null +++ b/desm-core/src/main/java/org/example/desm/common/ResponseErrorExceptions.java @@ -0,0 +1,12 @@ +package org.example.desm.common; + +import org.example.desm.common.dto.ErrorResponse; + +/** + * Custom {@link Exception} which is thrown when there is an error response from the server. + */ +public class ResponseErrorExceptions extends Exception { + public ResponseErrorExceptions(ErrorResponse error) { + super(error.toString()); + } +} diff --git a/desm-core/src/main/java/org/example/desm/common/RetryExecutor.java b/desm-core/src/main/java/org/example/desm/common/RetryExecutor.java new file mode 100644 index 0000000..fcb4cd8 --- /dev/null +++ b/desm-core/src/main/java/org/example/desm/common/RetryExecutor.java @@ -0,0 +1,58 @@ +package org.example.desm.common; + +/** + * Utility class to execute an operation with retry logic. + * This class allows retrying a given operation, defined by a {@link Runnable}, + * a specified number of times, with a configurable delay between attempts. + */ +public class RetryExecutor { + /** + * Represents an operation to be executed with retry logic. + */ + public interface Operation { + /** + * Executes the operation. + * + * @throws Exception if the operation fails. + */ + void run() throws Exception; + } + + /** + * Executes a given operation with retry logic. + * + * @param operation The operation to execute, defined as a {@link Operation}. + * @param maxRetries The maximum number of times to retry the operation if it fails. + * @param retryIntervalMs The time to wait in milliseconds between retries. + * @param operationName A descriptive name for the operation, used in log messages. + */ + public static void executeWithRetries( + Operation operation, + int maxRetries, + long retryIntervalMs, + String operationName + ) throws IllegalStateException { + for (int attempt = 1; attempt <= maxRetries; attempt++) { + String maxRetriesStr = maxRetries == Integer.MAX_VALUE ? "unlimited" : String.valueOf(maxRetries); + try { + System.out.printf("[%s] Attempt %d of %s...%n", operationName, attempt, maxRetriesStr); + operation.run(); + return; + } catch (Exception e) { + System.err.printf("[%s] Attempt %d failed: %s%n", operationName, attempt, e.getMessage()); + if (attempt < maxRetries) { + System.out.printf("[%s] Retrying in %d ms...%n", operationName, retryIntervalMs); + try { + Thread.sleep(retryIntervalMs); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + System.err.printf("[%s] Execution interrupted while waiting for retry.%n", operationName); + } + } else { + System.err.printf("[%s] All %s attempts failed. Could not complete the operation.%n", operationName, maxRetriesStr); + } + } + } + throw new IllegalStateException("Operation failed after all retries."); + } +} \ No newline at end of file diff --git a/desm-core/src/main/java/org/example/desm/common/Simulation.java b/desm-core/src/main/java/org/example/desm/common/Simulation.java new file mode 100644 index 0000000..95ac8b4 --- /dev/null +++ b/desm-core/src/main/java/org/example/desm/common/Simulation.java @@ -0,0 +1,80 @@ +package org.example.desm.common; + +import com.google.gson.Gson; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttConnectOptions; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence; +import org.example.desm.common.dto.EnergyRequest; +import org.example.desm.common.dto.Measurement; + +import java.util.List; +import java.util.Random; +import java.util.UUID; + +import static org.example.desm.common.Constant.MQTT_BROKER_ADDRESS; +import static org.example.desm.common.Constant.TOPIC_CO2_MEASUREMENT; +import static org.example.desm.common.Constant.TOPIC_ENERGY_REQUEST; + +public class Simulation { + public static void main(String[] args) throws MqttException { + publishEnergyRequest(); + // publishMeasurement(); + } + + public static void publishEnergyRequest() throws MqttException { + Random random = new Random(); + Gson gson = new Gson(); + + MqttClient client = new MqttClient( + MQTT_BROKER_ADDRESS, + MqttClient.generateClientId(), + new MemoryPersistence() + ); + + MqttConnectOptions options = new MqttConnectOptions(); + options.setCleanSession(true); + options.setAutomaticReconnect(true); + client.connect(options); + + String id = UUID.randomUUID().toString(); + int power = random.nextInt((15000 - 5000) + 1) + 5000; + long timestamp = System.currentTimeMillis(); + EnergyRequest energyRequest = new EnergyRequest(id, power, timestamp); + + MqttMessage mqttMessage = new MqttMessage( + gson.toJson(energyRequest).getBytes() + ); + mqttMessage.setQos(2); + client.publish(TOPIC_ENERGY_REQUEST, mqttMessage); + client.disconnect(); + } + + public static void publishMeasurement() throws MqttException { + Gson gson = new Gson(); + + MqttClient client = new MqttClient( + MQTT_BROKER_ADDRESS, + MqttClient.generateClientId(), + new MemoryPersistence() + ); + + MqttConnectOptions options = new MqttConnectOptions(); + options.setCleanSession(true); + options.setAutomaticReconnect(true); + client.connect(options); + + List measurements = List.of( + new Measurement("id1", "co2", 400, 1000), + new Measurement("id2", "co2", 200, 2000) + ); + + MqttMessage mqttMessage = new MqttMessage( + gson.toJson(measurements).getBytes() + ); + mqttMessage.setQos(2); + client.publish(TOPIC_CO2_MEASUREMENT, mqttMessage); + client.disconnect(); + } +} diff --git a/desm-core/src/main/java/org/example/desm/common/dto/AverageEmission.java b/desm-core/src/main/java/org/example/desm/common/dto/AverageEmission.java new file mode 100644 index 0000000..f84efba --- /dev/null +++ b/desm-core/src/main/java/org/example/desm/common/dto/AverageEmission.java @@ -0,0 +1,20 @@ +package org.example.desm.common.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor +@Data +public class AverageEmission { + private long startTimestamp; + private long endTimestamp; + private double average_co2; + + @Override + public String toString() { + return "[from: %d, to: %d] avg CO2: %.2fg" + .formatted(startTimestamp, endTimestamp, average_co2); + } +} diff --git a/desm-core/src/main/java/org/example/desm/common/dto/EnergyRequest.java b/desm-core/src/main/java/org/example/desm/common/dto/EnergyRequest.java new file mode 100644 index 0000000..2cd182d --- /dev/null +++ b/desm-core/src/main/java/org/example/desm/common/dto/EnergyRequest.java @@ -0,0 +1,14 @@ +package org.example.desm.common.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor +@Data +public class EnergyRequest { + private String id; + private long kwhPower; + private long timestamp; +} diff --git a/desm-core/src/main/java/org/example/desm/common/dto/ErrorResponse.java b/desm-core/src/main/java/org/example/desm/common/dto/ErrorResponse.java new file mode 100644 index 0000000..b164639 --- /dev/null +++ b/desm-core/src/main/java/org/example/desm/common/dto/ErrorResponse.java @@ -0,0 +1,19 @@ +package org.example.desm.common.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor +@Data +public class ErrorResponse { + private int status; + private String error; + private String message; + + @Override + public String toString() { + return "[" + status + "] " + error + ": " + message; + } +} diff --git a/desm-core/src/main/java/org/example/desm/common/dto/Measurement.java b/desm-core/src/main/java/org/example/desm/common/dto/Measurement.java new file mode 100644 index 0000000..aaea327 --- /dev/null +++ b/desm-core/src/main/java/org/example/desm/common/dto/Measurement.java @@ -0,0 +1,27 @@ +package org.example.desm.common.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor +@Data +public class Measurement implements Comparable { + private String id; + private String type; + private double value; + private long timestamp; + + @Override + public int compareTo(Measurement m) { + Long thisTimestamp = timestamp; + Long otherTimestamp = m.getTimestamp(); + return thisTimestamp.compareTo(otherTimestamp); + } + + @Override + public String toString(){ + return value + " " + timestamp; + } +} diff --git a/desm-core/src/main/java/org/example/desm/common/dto/PowerPlant.java b/desm-core/src/main/java/org/example/desm/common/dto/PowerPlant.java new file mode 100644 index 0000000..3c09df8 --- /dev/null +++ b/desm-core/src/main/java/org/example/desm/common/dto/PowerPlant.java @@ -0,0 +1,37 @@ +package org.example.desm.common.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@NoArgsConstructor +@AllArgsConstructor +@Data +public class PowerPlant implements Comparable { + private String id; + private String address; + private int port; + + @Override + public int compareTo(PowerPlant o) { + return CharSequence.compare(id, o.id); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof PowerPlant that)) return false; + return Objects.equals(getId(), that.getId()); + } + + @Override + public int hashCode() { + return Objects.hashCode(getId()); + } + + @Override + public String toString() { + return "{id: %s, address: %s, port: %d}".formatted(id, address, port); + } +} diff --git a/desm-core/src/test/java/org/example/desm/common/PeriodicMqttMessageSenderTest.java b/desm-core/src/test/java/org/example/desm/common/PeriodicMqttMessageSenderTest.java new file mode 100644 index 0000000..006b4be --- /dev/null +++ b/desm-core/src/test/java/org/example/desm/common/PeriodicMqttMessageSenderTest.java @@ -0,0 +1,84 @@ +package org.example.desm.common; + +import org.eclipse.paho.client.mqttv3.IMqttClient; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class PeriodicMqttMessageSenderTest { + @Mock + IMqttClient client; + + @Captor + ArgumentCaptor messageCaptor; + + PeriodicMessageSender simulator; + final String topic = "test"; + final int qos = 2; + final static String message = "message test"; + + @BeforeEach + void setUp() { + simulator = new PeriodicMessageSender(client, topic, qos, 10); + } + + @Test + void testPublishesMessage() throws MqttException, InterruptedException { + simulator.start(); + // Wait to ensure the message is sent + Thread.sleep(200); + simulator.stopMeGently(); + simulator.join(); + + verify(client, atLeastOnce()).publish(eq(topic), messageCaptor.capture()); + + MqttMessage capturedMessage = messageCaptor.getValue(); + assertNotNull(capturedMessage); + + String payload = new String(capturedMessage.getPayload()); + + assertEquals(message, payload); + assertEquals(qos, capturedMessage.getQos()); + } + + @Test + void testException() throws MqttException, InterruptedException { + doThrow(new MqttException(MqttException.REASON_CODE_CLIENT_EXCEPTION)) + .when(client) + .publish(anyString(), any(MqttMessage.class)); + + simulator.start(); + // Wait to ensure the message is sent + Thread.sleep(200); + simulator.stopMeGently(); + simulator.join(); + + verify(client, atLeastOnce()).publish(anyString(), any(MqttMessage.class)); + } + + private static class PeriodicMessageSender extends PeriodicMqttMessageSender { + public PeriodicMessageSender(IMqttClient client, String topic, int qos, int intervalMs) { + super(client, topic, qos, intervalMs); + } + + @Override + protected String getMqttMessage() { + return message; + } + } +} \ No newline at end of file diff --git a/desm-core/src/test/java/org/example/desm/common/RetryExecutorTest.java b/desm-core/src/test/java/org/example/desm/common/RetryExecutorTest.java new file mode 100644 index 0000000..9b09ab3 --- /dev/null +++ b/desm-core/src/test/java/org/example/desm/common/RetryExecutorTest.java @@ -0,0 +1,82 @@ +package org.example.desm.common; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RetryExecutorTest { + @Mock + RetryExecutor.Operation mockRunnable; + + @BeforeEach + void setUp() { + mockRunnable = Mockito.mock(RetryExecutor.Operation.class); + } + + @Test + void testExecuteRunnableSuccessOnFirstAttempt() throws Exception { + doNothing().when(mockRunnable).run(); + + RetryExecutor.executeWithRetries(mockRunnable, 3, 1, "TestRunnable"); + + verify(mockRunnable, times(1)).run(); + } + + @Test + void testExecuteRunnableSuccessOnLaterAttempt() throws Exception { + doThrow(new RuntimeException()) + .doThrow(new RuntimeException()) + .doNothing() + .when(mockRunnable).run(); + + RetryExecutor.executeWithRetries(mockRunnable, 3, 1, "TestRunnableRetry"); + + verify(mockRunnable, times(3)).run(); + } + + @Test + void testExecuteRunnableAllAttemptsFail() throws Exception { + doThrow(new RuntimeException()).when(mockRunnable).run(); + + RuntimeException thrown = assertThrows(RuntimeException.class, () -> + RetryExecutor.executeWithRetries(mockRunnable, 3, 1, "TestRunnableAllFail")); + + verify(mockRunnable, times(3)).run(); + assertEquals(IllegalStateException.class, thrown.getClass()); + } + + @Test + void testExecuteRunnableInterruptedException() throws Exception { + doThrow(new RuntimeException()).when(mockRunnable).run(); + + Thread testThread = new Thread(() -> { + try { + RetryExecutor.executeWithRetries(mockRunnable, 2, 100, "TestRunnableInterrupt"); + fail(); + } catch (Exception e) { + assertTrue(e instanceof InterruptedException || + e.getCause() instanceof InterruptedException); + } + }); + + testThread.start(); + try { + TimeUnit.MILLISECONDS.sleep(50); + testThread.interrupt(); + testThread.join(); + } catch (InterruptedException e) { + fail(); + } + + verify(mockRunnable, atLeast(1)).run(); + } +} \ No newline at end of file diff --git a/desm-network/build.gradle b/desm-network/build.gradle new file mode 100644 index 0000000..1270a04 --- /dev/null +++ b/desm-network/build.gradle @@ -0,0 +1,38 @@ +plugins { + id "com.google.protobuf" version "0.9.4" +} + +application { + mainClassName = "org.example.desm.network.PowerPlantApplication" +} + +run { + standardInput = System.in +} + +String protobufVersion = "3.13.0" +String grpcVersion = "1.25.0" +dependencies { + implementation "com.google.protobuf:protobuf-java:$protobufVersion" + implementation "io.grpc:grpc-api:$grpcVersion" + implementation "io.grpc:grpc-stub:$grpcVersion" + implementation "io.grpc:grpc-protobuf:$grpcVersion" + implementation "io.grpc:grpc-netty:$grpcVersion" + implementation 'javax.annotation:javax.annotation-api:1.3.2' +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:$protobufVersion" + } + plugins { + grpc { + artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion" + } + } + generateProtoTasks { + all()*.plugins { + grpc {} + } + } +} diff --git a/desm-network/src/main/java/org/example/desm/network/PeerContext.java b/desm-network/src/main/java/org/example/desm/network/PeerContext.java new file mode 100644 index 0000000..b67df37 --- /dev/null +++ b/desm-network/src/main/java/org/example/desm/network/PeerContext.java @@ -0,0 +1,91 @@ +package org.example.desm.network; + +import lombok.Getter; +import org.example.desm.common.dto.PowerPlant; +import org.example.desm.network.structure.BusyMonitor; +import org.example.desm.network.protocol.ElectionManager; +import org.example.desm.network.protocol.NetworkManager; +import org.example.desm.network.structure.ElectionRepository; +import org.example.desm.network.structure.EnergyRequestQueue; +import org.example.desm.network.structure.NetworkRepository; +import org.example.desm.network.structure.PeerLock; + +/** + * Abstract context class for a peer in the network. + */ +public abstract class PeerContext { + @Getter protected final PowerPlant currentPowerPlant; + @Getter protected final NetworkRepository networkRepository; + @Getter protected final ElectionRepository electionRepository; + + @Getter protected final PeerLock peerLock = new PeerLock(); + @Getter protected final BusyMonitor busyMonitor = new BusyMonitor(); + @Getter protected final EnergyRequestQueue requestQueue = new EnergyRequestQueue(); + + @Getter protected final NetworkManager networkManager = new NetworkManager(this); + @Getter protected final ElectionManager electionManager = new ElectionManager(this); + + private RingState state = RingState.IDLE; + + /** + * Creates a new peer context. + * + * @param id The id of the peer. + * @param address The address of the peer. + * @param port The port of the peer. + */ + public PeerContext(String id, String address, int port) { + this.currentPowerPlant = new PowerPlant(id, address, port); + this.networkRepository = new NetworkRepository(currentPowerPlant); + this.electionRepository = new ElectionRepository(id); + } + + public String getId() { + return currentPowerPlant.getId(); + } + + public String getAddress() { + return currentPowerPlant.getAddress(); + } + + public int getPort() { + return currentPowerPlant.getPort(); + } + + public synchronized RingState getState() { + return state; + } + + /** + * Sets the state of the current peer. + * + * @param state the new state of the current peer. + */ + public synchronized void setState(RingState state) { + if (this.state == state) { + return; + } + + System.out.printf("[State] State changed from %s to %s.%n", this.state, state); + this.state = state; + + // Subscribe to the energy request topic when the peer joins the ring + if (state == RingState.PARTICIPANT) { + startHandleEnergyRequest(); + } + } + + /** + * Checks if the current peer is a participant of the ring. + * + * @return {@code true} if the current peer is a participant, {@code false} otherwise. + */ + public synchronized boolean isParticipant() { + return state != null && state.equals(RingState.PARTICIPANT); + } + + /** + * Abstract method to start handling energy request. + */ + abstract void startHandleEnergyRequest(); +} diff --git a/desm-network/src/main/java/org/example/desm/network/PowerPlantApplication.java b/desm-network/src/main/java/org/example/desm/network/PowerPlantApplication.java new file mode 100644 index 0000000..f8a8590 --- /dev/null +++ b/desm-network/src/main/java/org/example/desm/network/PowerPlantApplication.java @@ -0,0 +1,175 @@ +package org.example.desm.network; + +import org.eclipse.paho.client.mqttv3.MqttException; +import org.example.desm.common.ResponseErrorExceptions; + +import java.io.IOException; +import java.util.Scanner; + +public class PowerPlantApplication { + public static void main(String[] args) { + if (args.length > 0) { + coldInit(Integer.parseInt(args[0])); + } else { + init(); + } + } + + /** + * Cold initialization flow for the Power Plant peer, used exclusively for testing. + */ + private static void coldInit(int i) { + String id = "p" + i; + String address = "localhost"; + int port = 9000 + i; + PowerPlantPeer peer = null; + try (Scanner scanner = new Scanner(System.in)) { + peer = new PowerPlantPeer(id, address, port); + peer.start(); + runCommandLoop(scanner, peer); + } catch (Exception e) { + System.err.println(e.getMessage()); + } finally { + if (peer != null) { + peer.shutdown(); + } + } + } + + /** + * Standard initialization flow for the Power Plant peer. + */ + private static void init() { + PowerPlantPeer peer = null; + try (Scanner scanner = new Scanner(System.in)) { + peer = initializePeer(scanner); + if (peer == null) { + System.err.println("Failed to initialize Power Plant. Exiting..."); + return; + } + + runCommandLoop(scanner, peer); + } finally { + if (peer != null) { + peer.shutdown(); + } + } + } + + /** + * Initializes the Power Plant peer by requesting input from the user. + * + * @param scanner Scanner to read input. + * @return A successfully initialized {@link PowerPlantPeer} or {@code null} if failed. + */ + private static PowerPlantPeer initializePeer(Scanner scanner) { + PowerPlantPeer peer = null; + while (peer == null) { + // Prompt for ID + System.out.print("Enter Power Plant ID: "); + String id = scanner.nextLine().trim(); + + // Prompt for address (localhost as default) + System.out.print("Enter address (press Enter to use 'localhost'): "); + String addressInput = scanner.nextLine().trim(); + String address = addressInput.isBlank() ? "localhost" : addressInput; + + // Prompt for port + int port = -1; + while (port < 0) { + System.out.print("Enter port number: "); + String portInput = scanner.nextLine().trim(); + try { + port = Integer.parseInt(portInput); + if (port < 0 || port > 65535) { + System.out.println("Port must be between 0 and 65535."); + port = -1; + } + } catch (NumberFormatException e) { + System.out.println("Invalid number! Please enter a valid port."); + } + } + + try { + peer = new PowerPlantPeer(id, address, port); + System.out.printf("Power Plant initialized: %s%n%n", peer.getCurrentPowerPlant()); + } catch (MqttException e) { + System.err.printf("[MQTT] Initialization error in PowerPlantPeer: %s", e.getMessage()); + break; + } + + try { + peer.start(); + } catch (ResponseErrorExceptions e) { + handleStartupError(peer, e.getMessage()); + peer = null; + } catch (MqttException e) { + handleStartupError(peer, "[MQTT] Error on MQTT client: " + e.getMessage()); + peer = null; + } catch (IOException | InterruptedException e) { + handleStartupError(peer, "An unexpected error occurred while starting the Power Plant: " + e.getMessage()); + peer = null; + } + } + return peer; + } + + /** + * Handles errors that occur during the initialization of the Power Plant. + * + * @param peer the Power Plant peer that failed to initialize. + * @param message the error message to print. + */ + private static void handleStartupError(PowerPlantPeer peer, String message) { + if (peer != null) { + peer.shutdown(); + } + + System.out.println(message); + System.out.println("Retrying initialization of Power Plant...\n"); + } + + /** + * Runs the command loop for the Power Plant peer. + * + * @param scanner the scanner to read user input. + * @param peer the Power Plant peer to run the command loop for. + */ + private static void runCommandLoop(Scanner scanner, PowerPlantPeer peer) { + System.out.println(); + System.out.println("**************************************************"); + System.out.println("***** Type 'exit' to disconnect and shutdown *****"); + System.out.println("***** Type 'info' for current peer info *****"); + System.out.println("**************************************************"); + System.out.println(); + + boolean isRunning = true; + while (isRunning) { + String command = scanner.nextLine().trim().toLowerCase(); + switch (command) { + case "exit": + isRunning = false; + break; + case "info": + printInfo(peer); + break; + default: + System.out.println("Unknown command."); + } + } + } + + /** + * Prints information about the Power Plant peer. + * + * @param peer the Power Plant peer to print information for. + */ + private static void printInfo(PowerPlantPeer peer) { + System.out.println("*********************************"); + System.out.println("***** Power Plant Peer info *****"); + System.out.println("*********************************"); + System.out.printf(peer.toString()); + System.out.println("*********************************"); + System.out.println(); + } +} diff --git a/desm-network/src/main/java/org/example/desm/network/PowerPlantPeer.java b/desm-network/src/main/java/org/example/desm/network/PowerPlantPeer.java new file mode 100644 index 0000000..9e8a5f6 --- /dev/null +++ b/desm-network/src/main/java/org/example/desm/network/PowerPlantPeer.java @@ -0,0 +1,363 @@ +package org.example.desm.network; + +import io.grpc.ManagedChannel; +import io.grpc.Server; +import io.grpc.ServerBuilder; +import org.eclipse.paho.client.mqttv3.IMqttClient; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttConnectOptions; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence; +import org.example.desm.common.ApiClient; +import org.example.desm.common.ResponseErrorExceptions; +import org.example.desm.common.RetryExecutor; +import org.example.desm.common.dto.PowerPlant; +import org.example.desm.grpc.RingServiceGrpc; +import org.example.desm.grpc.RingServiceOuterClass; +import org.example.desm.network.pollution.PeriodicMeasurementSender; +import org.example.desm.network.protocol.ElectionDaemon; +import org.example.desm.network.protocol.GrpcUtils; +import org.example.desm.network.structure.SlidingWindowBuffer; +import org.example.desm.network.structure.Buffer; +import org.example.desm.network.pollution.PollutionSensor; +import org.example.desm.network.protocol.RingService; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.example.desm.common.Constant.MQTT_BROKER_ADDRESS; +import static org.example.desm.common.Constant.TOPIC_CO2_MEASUREMENT; +import static org.example.desm.common.Constant.TOPIC_ENERGY_REQUEST; + +/** + * This class represents a power plant peer in the network. + * It extends the {@link PeerContext} class. + */ +public class PowerPlantPeer extends PeerContext { + private static final long RETRY_INTERVAL_MS = 10000; + private static final int MAX_RETRIES = Integer.MAX_VALUE; + private static final int SUB_QOS = 2; + + private final Server grpcServer; + private final IMqttClient mqttClient; + private final PollutionSensor pollutionSensor; + private final PeriodicMeasurementSender periodicSendMeasurement; + + private final ElectionDaemon electionDaemon = new ElectionDaemon(this); + + /** + * Creates a new {@link PowerPlantPeer}. + * + * @param id The ID of the power plant peer. + * @param address The address of the power plant peer. + * @param port The port of the power plant peer. + * @throws MqttException if there is an error initializing the MQTT client. + */ + public PowerPlantPeer(String id, String address, int port) throws MqttException { + super(id, address, port); + + // Initialize gRPC server with ring service + RingService ringService = new RingService(this); + this.grpcServer = ServerBuilder.forPort(port) + .addService(ringService) + .build(); + + // Initialize MQTT client + this.mqttClient = new MqttClient( + MQTT_BROKER_ADDRESS, + MqttClient.generateClientId(), + new MemoryPersistence() + ); + + // Initialize pollution sensor and periodic measurement sender + Buffer measurementBuffer = new SlidingWindowBuffer(id); + this.pollutionSensor = new PollutionSensor(measurementBuffer); + this.periodicSendMeasurement = new PeriodicMeasurementSender( + measurementBuffer, + mqttClient, + TOPIC_CO2_MEASUREMENT + ); + } + + /** + * Subscribes to the energy request topic on the MQTT broker and + * then starts the election daemon. + */ + @Override + public void startHandleEnergyRequest() { + new Thread(this::subscribeToEnergyRequestTopic).start(); + + System.out.println("Starting election daemon..."); + electionDaemon.start(); + System.out.println("Election daemon started!"); + } + + /** + * Subscribes to the energy request topic on the MQTT broker with a retry mechanism + * if the subscription fails. + */ + private void subscribeToEnergyRequestTopic() { + try { + RetryExecutor.executeWithRetries( + () -> { + System.out.printf("[MQTT] Subscribing to topic %s...%n", TOPIC_ENERGY_REQUEST); + mqttClient.subscribe(TOPIC_ENERGY_REQUEST, SUB_QOS); + System.out.println("[MQTT] Subscribed successfully."); + }, + MAX_RETRIES, + RETRY_INTERVAL_MS, + "Subscription to topic: " + TOPIC_ENERGY_REQUEST + ); + } catch (IllegalStateException e) { + System.err.printf("[MQTT] Critical error during MQTT subscription to topic %s: %s%n", + TOPIC_ENERGY_REQUEST, e.getMessage()); + } + } + + /** + * Starts the Power Plant. + * + * @throws IOException if there is an error during the gRPC server startup or registration process. + * @throws InterruptedException if the thread is interrupted during the registration process. + * @throws MqttException if there is an error connecting to the MQTT broker. + * @throws ResponseErrorExceptions if there is an error response from the API during the registration. + */ + public void start() throws IOException, InterruptedException, MqttException, ResponseErrorExceptions { + startGrpcServer(); + connectToMqttBroker(); + registerToNetwork(); + startPollutionDetection(); + presentsItselfToNetwork(); + } + + /** + * Shuts down the Power Plant. + */ + public void shutdown() { + try { + System.out.println("\nShutting down..."); + stopGrpcServer(); + disconnectFromMqttBroker(); + stopElectionProcedure(); + networkRepository.shutdownSuccessorChannel(); + stopPollutionDetection(); + System.out.println("Shutdown complete!\n"); + } catch (MqttException e) { + System.err.printf("[MQTT] Error during MQTT client disconnection: %s%n", e.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + System.out.println("Shutdown interrupted."); + } + } + + /** + * Starts the gRPC server for this Power Plant. + * + * @throws IOException if the server fails to start. + */ + private void startGrpcServer() throws IOException { + System.out.println("[gRPC] Starting server..."); + grpcServer.start(); + System.out.printf("[gRPC] Server started! Listening on port %d.%n", currentPowerPlant.getPort()); + + // Add a shutdown hook to stop the gRPC server cleanly. + // Best practice seen on: + // https://github.com/grpc/grpc-java/blob/master/examples/src/main/java/io/grpc/examples/helloworld/HelloWorldServer.java + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + try { + stopGrpcServer(); + } catch (InterruptedException e) { + e.printStackTrace(System.err); + } + })); + } + + /** + * Connects to the MQTT broker using the configured client. + * + * @throws MqttException if there is an error connecting to the broker. + */ + private void connectToMqttBroker() throws MqttException { + MqttConnectOptions options = new MqttConnectOptions(); + options.setCleanSession(true); + options.setAutomaticReconnect(true); + + System.out.printf("[MQTT] Connecting to broker %s...%n", MQTT_BROKER_ADDRESS); + PowerRequestCallback callback = new PowerRequestCallback(requestQueue); + mqttClient.setCallback(callback); + mqttClient.connect(options); + System.out.println("[MQTT] Connected to broker!"); + } + + /** + * Registers the power plant to the network. + * + * @throws IOException if there is an error during the registration process. + * @throws InterruptedException if the thread is interrupted during the registration process. + * @throws ResponseErrorExceptions if there is an error response from the API. + */ + private void registerToNetwork() + throws IOException, InterruptedException, ResponseErrorExceptions { + System.out.println("Registering to network..."); + PowerPlant dto = getCurrentPowerPlant(); + List plants = ApiClient.registerPlant(dto); + networkRepository.addAll(plants); + System.out.println("Registered to network!"); + System.out.printf("Network size: %d nodes%n", plants.size()); + } + + /** + * Starts the pollution detection process by starting the pollution sensor and the periodic measurement sender. + */ + private void startPollutionDetection() { + System.out.println("Starting pollution sensor..."); + pollutionSensor.start(); + System.out.println("Pollution sensor started!"); + + System.out.println("Starting periodic measurement sender..."); + periodicSendMeasurement.start(); + System.out.println("Periodic measurement sender started!"); + } + + /** + * Presents itself to the network. + *

+ * If there are no other plants in the network, this is the first node and + * changes the state to {@link RingState#PARTICIPANT}. + * Otherwise, it tries to join the ring contacting the possible predecessors + * of the current node in the network ring, until it succeeds or there are no + * more predecessors. + */ + private void presentsItselfToNetwork() { + System.out.println("Presenting itself to the network..."); + setState(RingState.JOINING); + + // If there are no other plants in the network, this is the first node. + if (!networkRepository.thereIsSomeoneElse()) { + System.out.println("No other plants in the network, this is the first node."); + // Itself as successor, self-loop + networkManager.joinRing(currentPowerPlant); + return; + } + + List predecessorsChain = networkRepository.getPredecessorsChain(); + boolean accepted = false; + int i = 0; + while (!accepted && i < predecessorsChain.size()) { + PowerPlant target = predecessorsChain.get(i); + i++; + if (target == null) { + continue; + } + + System.out.printf("Attempting to join ring through peer %s...%n", target.getId()); + accepted = networkManager.initJoinRequest(target); + } + + if (!accepted) { + System.err.println("No available peer to contact for joining the ring."); + } + } + + /** + * Stops the gRPC server if it is running. + * + * @throws InterruptedException if the thread is interrupted while waiting for the server to terminate. + */ + private void stopGrpcServer() throws InterruptedException { + if (grpcServer != null && !grpcServer.isShutdown()) { + System.out.println("[gRPC] Shutting down server..."); + grpcServer.shutdown().awaitTermination(30, TimeUnit.SECONDS); + System.out.println("[gRPC] Server shut down!"); + } + } + + /** + * Disconnects from the MQTT broker if connected. + * + * @throws MqttException if there is an error during disconnection. + */ + private void disconnectFromMqttBroker() throws MqttException { + if (mqttClient.isConnected()) { + System.out.printf("[MQTT] Disconnecting from broker %s...%n", MQTT_BROKER_ADDRESS); + mqttClient.disconnect(); + System.out.println("[MQTT] Disconnected from broker!"); + } + } + + /** + * Stops the pollution detection process by interrupting the pollution sensor and the periodic measurement sender. + * + * @throws InterruptedException if the thread is interrupted while waiting for the sensors to finish. + */ + private void stopPollutionDetection() throws InterruptedException { + if (pollutionSensor.isAlive()) { + System.out.println("Stopping pollution sensor..."); + pollutionSensor.stopMeGently(); + + System.out.println("Waiting for pollution sensor to finish..."); + pollutionSensor.join(); + System.out.println("Pollution sensor has finished."); + } + + if (periodicSendMeasurement.isAlive()) { + System.out.println("Stopping periodic measurement sender..."); + periodicSendMeasurement.stopMeGently(); + + System.out.println("Waiting for periodic measurement sender to finish..."); + periodicSendMeasurement.join(); + System.out.println("Periodic measurement sender has finished."); + } + } + + /** + * Stops the election procedure. + * + * @throws InterruptedException if the thread is interrupted while waiting for the election managers to finish. + */ + private void stopElectionProcedure() throws InterruptedException { + requestQueue.close(); + + if (electionDaemon.isAlive()) { + System.out.println("Stopping election manager..."); + electionDaemon.stopMeGently(); + + System.out.println("Waiting for election manager to finish..."); + electionDaemon.join(); + System.out.println("Election manager has finished."); + } + } + + @Override + public String toString() { + Collection participants = networkRepository.getParticipants() + .stream().map(PowerPlant::getId).toList(); + PowerPlant successor = networkRepository.getSuccessor(); + ManagedChannel successorChannel = networkRepository.getSuccessorChannel(); + RingServiceGrpc.RingServiceStub successorStub = networkRepository.getSuccessorStub(); + int pendingRequests = requestQueue.size(); + boolean isBusy = busyMonitor.isBusy(); + RingServiceOuterClass.ElectedMessage lastElection = electionRepository.getLastElectionWon(); + Collection electionsWon = electionRepository.getElectionsWon() + .stream().map(electedMessage -> electedMessage.getEnergyRequest().getId()).toList(); + Collection completedElections = electionRepository.getCompletedElections() + .stream().map(electedMessage -> electedMessage.getEnergyRequest().getId()).toList(); + + return "Current node: " + getCurrentPowerPlant() + "\n" + + "State: " + getState() + "\n" + + "Number of network participants: " + participants.size() + " nodes\n" + + "Network: " + participants + "\n" + + "Successor: " + (successor != null ? successor : "None") + "\n" + + "Successor channel: " + (successorChannel != null ? "Available" : "Unavailable") + "\n" + + "Successor stub: " + (successorStub != null ? "Available" : "Unavailable") + "\n" + + "Pending requests: " + pendingRequests + "\n" + + "Is busy: " + isBusy + "\n" + + "Last election won: " + (lastElection != null ? GrpcUtils.toString(lastElection) : "None") + "\n" + + "Number of won elections: " + electionsWon.size() + "\n" + + "Won elections: " + electionsWon + "\n" + + "Number of completed elections: " + completedElections.size() + "\n" + + "Completed elections: " + completedElections + "\n"; + } +} diff --git a/desm-network/src/main/java/org/example/desm/network/PowerRequestCallback.java b/desm-network/src/main/java/org/example/desm/network/PowerRequestCallback.java new file mode 100644 index 0000000..e36e6db --- /dev/null +++ b/desm-network/src/main/java/org/example/desm/network/PowerRequestCallback.java @@ -0,0 +1,60 @@ +package org.example.desm.network; + +import com.google.gson.Gson; +import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken; +import org.eclipse.paho.client.mqttv3.MqttCallback; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.example.desm.common.dto.EnergyRequest; +import org.example.desm.grpc.RingServiceOuterClass; +import org.example.desm.network.structure.EnergyRequestQueue; + +import java.util.Arrays; + +import static org.example.desm.common.Constant.TOPIC_ENERGY_REQUEST; + +/** + * Callback class for handling MQTT messages related to energy requests. + */ +public class PowerRequestCallback implements MqttCallback { + private static final Gson gson = new Gson(); + + private final EnergyRequestQueue requestQueue; + + /** + * Constructor for the {@link PowerRequestCallback} class. + * + * @param requestQueue The queue to enqueue energy requests. + */ + public PowerRequestCallback(EnergyRequestQueue requestQueue) { + this.requestQueue = requestQueue; + } + + @Override + public void connectionLost(Throwable cause) { + System.err.println("[MQTT] Connection lost with MQTT broker: " + cause.getMessage()); + // Automatic reconnect is enabled, so no need to handle reconnection + } + + @Override + public void messageArrived(String topic, MqttMessage message) { + System.out.printf("[MQTT] Received message on topic %s: %s%n", topic, message); + if (topic.equals(TOPIC_ENERGY_REQUEST)) { + String payload = new String(message.getPayload()); + EnergyRequest request = gson.fromJson(payload, EnergyRequest.class); + + // Producer: enqueue the energy request + RingServiceOuterClass.EnergyRequest protoRequest = RingServiceOuterClass.EnergyRequest.newBuilder() + .setId(request.getId()) + .setKwhPower(request.getKwhPower()) + .setTimestamp(request.getTimestamp()) + .build(); + requestQueue.enqueue(protoRequest); + } + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + System.out.printf("[MQTT] Delivery complete on topics %s for message with ID %d.%n", + Arrays.toString(token.getTopics()), token.getMessageId()); + } +} diff --git a/desm-network/src/main/java/org/example/desm/network/RingState.java b/desm-network/src/main/java/org/example/desm/network/RingState.java new file mode 100644 index 0000000..42e79e2 --- /dev/null +++ b/desm-network/src/main/java/org/example/desm/network/RingState.java @@ -0,0 +1,10 @@ +package org.example.desm.network; + +/** + * Represents the state of the Power Plant in the ring overlay-network. + */ +public enum RingState { + IDLE, // Not participating in the ring + JOINING, // Joining the ring, not yet participating + PARTICIPANT, // Participating in the ring +} diff --git a/desm-network/src/main/java/org/example/desm/network/pollution/PeriodicMeasurementSender.java b/desm-network/src/main/java/org/example/desm/network/pollution/PeriodicMeasurementSender.java new file mode 100644 index 0000000..3b6c65c --- /dev/null +++ b/desm-network/src/main/java/org/example/desm/network/pollution/PeriodicMeasurementSender.java @@ -0,0 +1,72 @@ +package org.example.desm.network.pollution; + +import com.google.gson.Gson; +import org.eclipse.paho.client.mqttv3.IMqttClient; +import org.example.desm.common.PeriodicMqttMessageSender; +import org.example.desm.common.dto.Measurement; +import org.example.desm.network.structure.Buffer; + +import java.util.List; + +/** + * This class extend {@link PeriodicMqttMessageSender} and is responsible for periodically + * sending measurements from a buffer to a specified MQTT topic in JSON format. + */ +public class PeriodicMeasurementSender extends PeriodicMqttMessageSender { + private static final long INTERVAL_MS = 10000; + private static final int PUB_QOS = 2; + private static final Gson gson = new Gson(); + + private final Buffer measurementBuffer; + + /** + * Constructs {@link PeriodicMeasurementSender} with custom parameters. + * + * @param measurementBuffer the buffer containing measurements to be sent. + * @param client the MQTT client used to publish messages. + * @param topic the topic to publish energy requests to. + * @param qos the MQTT Quality of Service level. + * @param intervalMs the interval between requests in milliseconds. + */ + public PeriodicMeasurementSender( + Buffer measurementBuffer, + IMqttClient client, + String topic, + int qos, + long intervalMs + ) { + super(client, topic, qos, intervalMs); + this.measurementBuffer = measurementBuffer; + } + + /** + * Constructs {@link PeriodicMeasurementSender} with default parameters (QoS 2, interval 10 seconds). + * + * @param client the MQTT client used to publish messages. + * @param topic the topic to publish energy requests to. + */ + public PeriodicMeasurementSender( + Buffer measurementBuffer, + IMqttClient client, + String topic + ) { + this(measurementBuffer, client, topic, PUB_QOS, INTERVAL_MS); + } + + /** + * Read all the measurements from the buffer, converts them to JSON format, + * and returns the resulting string. + *

+ * If there are no measurements, it returns {@code null}. + * + * @return a JSON string representing the {@link Measurement} list, or {@code null} if there are no measurements. + */ + @Override + protected String getMqttMessage() { + List measurements = measurementBuffer.readAllAndClean(); + if (measurements.isEmpty()) { + return null; + } + return gson.toJson(measurements); + } +} diff --git a/desm-network/src/main/java/org/example/desm/network/pollution/PollutionSensor.java b/desm-network/src/main/java/org/example/desm/network/pollution/PollutionSensor.java new file mode 100644 index 0000000..86a041e --- /dev/null +++ b/desm-network/src/main/java/org/example/desm/network/pollution/PollutionSensor.java @@ -0,0 +1,36 @@ +package org.example.desm.network.pollution; + +import org.example.desm.network.structure.Buffer; + +public class PollutionSensor extends Simulator { + private static final int MEAN = 125000; + private static final int VARIANCE = 5000; + + private static int ID = 1; + + public PollutionSensor(String id, Buffer buffer) { + super(id, "CO2", buffer); + } + + // Use this constructor to initialize the Pollution Sensor simulator in your project + public PollutionSensor(Buffer buffer) { + this("CO2-" + ID++, buffer); + } + + @Override + public void run() { + long waitingTime; + while(!stopCondition) { + double co2 = getCO2Value(); + addMeasurement(co2); + + waitingTime = 2000; + sensorSleep(waitingTime); + } + } + + private double getCO2Value() { + double gaussian = rnd.nextGaussian(); + return MEAN + Math.sqrt(VARIANCE) * gaussian; + } +} diff --git a/desm-network/src/main/java/org/example/desm/network/pollution/Simulator.java b/desm-network/src/main/java/org/example/desm/network/pollution/Simulator.java new file mode 100644 index 0000000..9c4fbf4 --- /dev/null +++ b/desm-network/src/main/java/org/example/desm/network/pollution/Simulator.java @@ -0,0 +1,53 @@ +package org.example.desm.network.pollution; + +import org.example.desm.common.dto.Measurement; +import org.example.desm.network.structure.Buffer; + +import java.util.Random; + +public abstract class Simulator extends Thread { + protected volatile boolean stopCondition = false; + protected Random rnd = new Random(); + + private final Buffer buffer; + private final String id; + private final String type; + + public Simulator(String id, String type, Buffer buffer) { + this.id = id; + this.type = type; + this.buffer = buffer; + } + + public void stopMeGently() { + stopCondition = true; + } + + protected void addMeasurement(double measurement) { + buffer.addMeasurement(new Measurement(id, type, measurement, currentTime())); + } + + public Buffer getBuffer(){ + return buffer; + } + + public String getIdentifier() { + return id; + } + + protected void sensorSleep(long milliseconds) { + try { + Thread.sleep(milliseconds); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + System.out.println("Simulator interrupted."); + } + } + + private long currentTime() { + return System.currentTimeMillis(); + } + + public abstract void run(); +} + diff --git a/desm-network/src/main/java/org/example/desm/network/protocol/ElectionDaemon.java b/desm-network/src/main/java/org/example/desm/network/protocol/ElectionDaemon.java new file mode 100644 index 0000000..e2e5dee --- /dev/null +++ b/desm-network/src/main/java/org/example/desm/network/protocol/ElectionDaemon.java @@ -0,0 +1,76 @@ +package org.example.desm.network.protocol; + +import org.example.desm.grpc.RingServiceOuterClass; +import org.example.desm.network.PeerContext; +import org.example.desm.network.structure.PeerLock; +import org.example.desm.network.structure.BusyMonitor; +import org.example.desm.network.structure.EnergyRequestQueue; + +/** + * A daemon {@link Thread} that handles the election process for the power plant peer. + */ +public class ElectionDaemon extends Thread { + private volatile boolean stopCondition = false; + + private final PeerContext peer; + + /** + * Creates a {@link ElectionDaemon} for the given peer context. + * + * @param peer the peer context + */ + public ElectionDaemon(PeerContext peer) { + this.peer = peer; + } + + /** + * Stops the election manager gently. + */ + public void stopMeGently() { + stopCondition = true; + } + + @Override + public void run() { + PeerLock peerLock = peer.getPeerLock(); + BusyMonitor busyMonitor = peer.getBusyMonitor(); + EnergyRequestQueue requestQueue = peer.getRequestQueue(); + ElectionManager electionManager = peer.getElectionManager(); + while (!stopCondition) { + RingServiceOuterClass.EnergyRequest request = null; + try { + // Wait until plant is not busy + busyMonitor.waitUntilNotBusy(); + + // If stopCondition is true while waiting, skip the processing for this iteration + if (stopCondition) { + continue; + } + + // Consumer: dequeue energy requests and process them + request = requestQueue.dequeue(); + if (request != null) { + peerLock.acquireElection(); + + // After acquiring the peerLock, check the busy status again. + // If the plant becomes busy immediately before acquiring this lock, + // we should not proceed with the request processing. + if (busyMonitor.isBusy()) { + requestQueue.enqueue(request); + } else { + System.out.printf("[Election] Initiating election for %s%n", + GrpcUtils.toString(request)); + electionManager.processEnergyRequest(request); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + System.err.println("[Election] Election manager interrupted."); + } finally { + if (request != null) { + peerLock.releaseElection(); + } + } + } + } +} diff --git a/desm-network/src/main/java/org/example/desm/network/protocol/ElectionManager.java b/desm-network/src/main/java/org/example/desm/network/protocol/ElectionManager.java new file mode 100644 index 0000000..e0c7478 --- /dev/null +++ b/desm-network/src/main/java/org/example/desm/network/protocol/ElectionManager.java @@ -0,0 +1,418 @@ +package org.example.desm.network.protocol; + +import com.google.protobuf.Empty; +import io.grpc.stub.StreamObserver; +import org.example.desm.grpc.RingServiceGrpc; +import org.example.desm.grpc.RingServiceOuterClass; +import org.example.desm.network.PeerContext; +import org.example.desm.network.structure.BusyMonitor; +import org.example.desm.network.structure.ElectionMessageComparator; +import org.example.desm.network.structure.ElectionRepository; +import org.example.desm.network.structure.EnergyRequestQueue; +import org.example.desm.network.structure.NetworkRepository; + +import java.util.function.BiConsumer; + +/** + * Manages the election protocol. + */ +public class ElectionManager + extends ProtocolManager + implements ElectionMessageFactory { + private static final double MIN_BID_PRICE = 0.1; + private static final double MAX_BID_PRICE = 0.9; + private static final ElectionMessageComparator comparator = new ElectionMessageComparator(); + + /** + * Creates an {@link ElectionManager}. + * + * @param peer The peer context. + */ + public ElectionManager(PeerContext peer) { + super(peer); + } + + /** + * Builds a new {@link RingServiceOuterClass.ElectionMessage} for the given + * {@link RingServiceOuterClass.EnergyRequest} with a random bid price in the + * range [{@link #MIN_BID_PRICE}, {@link #MAX_BID_PRICE}]. + * + * @param energyRequest The energy request. + * @return A new ElectionMessage with this peer's ID and random bid price. + */ + @Override + public RingServiceOuterClass.ElectionMessage buildParticipationElectionMessage( + RingServiceOuterClass.EnergyRequest energyRequest + ) { + double bidPrice = MIN_BID_PRICE + (MAX_BID_PRICE - MIN_BID_PRICE) * Math.random(); + return buildParticipationElectionMessage(energyRequest, bidPrice); + } + + /** + * Builds a new {@link RingServiceOuterClass.ElectionMessage} for the given + * {@link RingServiceOuterClass.EnergyRequest} with the given bid price. + * + * @param energyRequest The energy request. + * @param bidPrice The bid price. + * @return A new ElectionMessage with this peer's ID and the given bid price. + */ + @Override + public RingServiceOuterClass.ElectionMessage buildParticipationElectionMessage( + RingServiceOuterClass.EnergyRequest energyRequest, + double bidPrice + ) { + return RingServiceOuterClass.ElectionMessage.newBuilder() + .setEnergyRequest(energyRequest) + .setCandidateId(peer.getId()) + .setCandidateBidPrice(bidPrice) + .build(); + } + + /** + * Processes an energy request by initiating a Chang-Roberts election. + *

+ * This method is called when a new request is dequeued from the {@link EnergyRequestQueue}. + *

+ * If the request in not already completed or ongoing (we already sent out a proposal), and is not + * older than the last seen one, creates a proposed {@link RingServiceOuterClass.ElectionMessage}, + * participates in the election and then sends the proposal to the successor. + * + * @param energyRequest The energy request. + */ + public void processEnergyRequest(RingServiceOuterClass.EnergyRequest energyRequest) { + ElectionRepository electionRepository = peer.getElectionRepository(); + + // If the election is already completed/ongoing or is older than the last seen one, ignore it + if (electionRepository.alreadyCompleted(energyRequest) || + electionRepository.alreadyParticipant(energyRequest) || + !electionRepository.validateAndRecordAttemptNumber(energyRequest)) { + System.out.printf("[Election] Ignoring election message for request %s: already completed/ongoing or old attempt%n", + GrpcUtils.toString(energyRequest)); + return; + } + + RingServiceOuterClass.ElectionMessage electionMessage = + electionRepository.participateInElection(energyRequest, this); + System.out.printf("[Election] Participate in election with: %s%n", GrpcUtils.toString(electionMessage)); + + forwardElectionMessage(electionMessage); + } + + /** + * Processes an election message according to the Chang-Roberts algorithm, representing + * a proposal for a specific energy request. + *

+ * When a process receives an election message, it performs the following steps in order: + *

+ *

+ * When a process starts wins, it begins the second stage of the algorithm: + *

+ * + * @param electionMessage The election message to process. + */ + public void processElectionMessage(RingServiceOuterClass.ElectionMessage electionMessage) { + BusyMonitor busyMonitor = peer.getBusyMonitor(); + ElectionRepository electionRepository = peer.getElectionRepository(); + RingServiceOuterClass.EnergyRequest energyRequest = electionMessage.getEnergyRequest(); + String candidateId = electionMessage.getCandidateId(); + + // If the election is completed or is older than the last seen one, do nothing + if (electionRepository.alreadyCompleted(energyRequest) || + !electionRepository.validateAndRecordAttemptNumber(energyRequest)) { + System.out.printf("[Election] Ignoring election message for request %s: already completed or old attempt%n", + GrpcUtils.toString(energyRequest)); + return; + } + + // If the message is from ourselves, and we are not busy, we've won + // If we are busy, we should reject the election win + if (peer.getId().equals(candidateId) && !busyMonitor.isBusy()) { + System.out.printf("[Election] Won election for request %s with bid price %f $/kWh%n", + GrpcUtils.toString(energyRequest), electionMessage.getCandidateBidPrice()); + electionWinner(electionMessage); + return; + } else if (peer.getId().equals(candidateId) && busyMonitor.isBusy()) { + System.out.printf("[Election] Rejected election win for request %s with bid price %f $/kWh: we are busy%n", + GrpcUtils.toString(energyRequest), electionMessage.getCandidateBidPrice()); + rejectElectionWin(electionMessage); + return; + } + + boolean alreadyParticipant = electionRepository.alreadyParticipant(energyRequest); + + // If is busy and not already a participant, just forward to successor and do not participate in election + // It's like leaving a blank vote during an election, or just ignoring the election + if (busyMonitor.isBusy() && !alreadyParticipant) { + System.out.printf("[Election] Current plant %s is busy, forwarding %s without participating.%n", + peer.getId(), GrpcUtils.toString(electionMessage)); + forwardElectionMessage(electionMessage); + return; + } + + // Participate in election if we are not already a participant + RingServiceOuterClass.ElectionMessage currentElection = + electionRepository.participateInElection(energyRequest, this); + if (!alreadyParticipant) { + System.out.printf("[Election] Participate in election with: %s%n", GrpcUtils.toString(currentElection)); + } + + int cmp = comparator.compare(currentElection, electionMessage); + if (cmp > 0) { + System.out.printf("[Election] Received propose wins, forwarding to successor: %s%n", + GrpcUtils.toString(electionMessage)); + forwardElectionMessage(electionMessage); + } else if (cmp < 0 && !alreadyParticipant) { + System.out.printf("[Election] Current propose wins and not yet participant, forwarding to successor: %s%n", + GrpcUtils.toString(currentElection)); + forwardElectionMessage(currentElection); + } else { + System.out.println("[Election] Discarding received propose: current propose wins and already participant"); + } + } + + /** + * Processes an elected message according to the Chang-Roberts algorithm, it indicates + * that the election is over. + *

+ * When a process receives an {@link RingServiceOuterClass.ElectedMessage}: + * + *

  • + * If the process is not the winner it marks itself as non-participant, records the + * completion of the election and forwards it to the successor. + *
  • + *
  • + * When it reaches the winner process (the one who won the election), it discards that message + * and the election is over. + *
  • + * + * + * @param electedMessage The elected message. + */ + public void processElectedMessage(RingServiceOuterClass.ElectedMessage electedMessage) { + String winnerId = electedMessage.getWinnerId(); + if (peer.getId().equals(winnerId)) { + System.out.printf("[Election] %s completed the round of ring.%n", + GrpcUtils.toString(electedMessage)); + return; + } + + electionCompleted(electedMessage); + } + + /** + * Processes a rejected message according to the Chang-Roberts algorithm, it indicates + * that the election has been rejected. + *

    + * When a process receives a {@link RingServiceOuterClass.RejectedMessage}: + * + *

  • + * If the process is not the winner requeue the request with higher attempt number + * and forwards it to the successor. + *
  • + *
  • + * When it reaches the winner process, it discards that message and the rejection + * of the election is over. + *
  • + * + * + * @param rejectedMessage The rejected message. + */ + public void processRejectedMessage(RingServiceOuterClass.RejectedMessage rejectedMessage) { + String winnerId = rejectedMessage.getWinnerId(); + if (peer.getId().equals(winnerId)) { + System.out.printf("[Election] %s completed the round of ring.%n", + GrpcUtils.toString(rejectedMessage)); + return; + } + + electionRejected(rejectedMessage); + } + + /** + * Announces the winner of an election. + * + * @param electionMessage The election message that won. + */ + private void electionWinner(RingServiceOuterClass.ElectionMessage electionMessage) { + RingServiceOuterClass.EnergyRequest energyRequest = electionMessage.getEnergyRequest(); + RingServiceOuterClass.ElectedMessage electedMessage = RingServiceOuterClass.ElectedMessage.newBuilder() + .setEnergyRequest(energyRequest) + .setWinnerId(electionMessage.getCandidateId()) + .setWinnerBidPrice(electionMessage.getCandidateBidPrice()) + .build(); + + electionCompleted(electedMessage); + requestFulfillment(energyRequest); + } + + /** + * Simulates the energy production for the given {@link RingServiceOuterClass.EnergyRequest} and + * announces the winner of the election. + * + * @param energyRequest The energy request. + */ + private void requestFulfillment(RingServiceOuterClass.EnergyRequest energyRequest) { + BusyMonitor busyMonitor = peer.getBusyMonitor(); + long fulfillmentTimeMs = energyRequest.getKwhPower(); + System.out.printf("[Production] Starting energy production for %s, estimated time: %d ms.%n", + GrpcUtils.toString(energyRequest), fulfillmentTimeMs); + + busyMonitor.setBusy(true); + new Thread(() -> { + try { + // Simulate energy production to fulfill the request + Thread.sleep(fulfillmentTimeMs); + System.out.printf("[Production] Finished energy production for: %s%n", + GrpcUtils.toString(energyRequest)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + System.err.printf("[Production] Energy production for %s interrupted.%n", + GrpcUtils.toString(energyRequest)); + } finally { + busyMonitor.setBusy(false); + System.out.println("[Production] Current power plant is now available again."); + } + }).start(); + } + + /** + * Rejects an election win with the given {@link RingServiceOuterClass.ElectionMessage}. + * + * @param electionMessage The election message. + */ + private void rejectElectionWin(RingServiceOuterClass.ElectionMessage electionMessage) { + RingServiceOuterClass.EnergyRequest energyRequest = electionMessage.getEnergyRequest(); + RingServiceOuterClass.RejectedMessage rejectedMessage = RingServiceOuterClass.RejectedMessage.newBuilder() + .setEnergyRequest(energyRequest) + .setWinnerId(electionMessage.getCandidateId()) + .build(); + + electionRejected(rejectedMessage); + } + + /** + * Completes an election with the given {@link RingServiceOuterClass.ElectedMessage}. + * + * @param electedMessage The elected message. + */ + private void electionCompleted(RingServiceOuterClass.ElectedMessage electedMessage) { + System.out.printf("[Election] Completed election for: %s%n", GrpcUtils.toString(electedMessage)); + peer.getElectionRepository().completeElection(electedMessage); + forwardElectedMessage(electedMessage); + } + + /** + * Rejects an election with the given {@link RingServiceOuterClass.RejectedMessage}. + * + * @param rejectedMessage The rejected message. + */ + private void electionRejected(RingServiceOuterClass.RejectedMessage rejectedMessage) { + RingServiceOuterClass.EnergyRequest energyRequest = rejectedMessage.getEnergyRequest(); + + // Requeue the not satisfied request with higher attempt number + RingServiceOuterClass.EnergyRequest newEnergyRequest = energyRequest.toBuilder() + .setAttemptNumber(energyRequest.getAttemptNumber() + 1) + .build(); + peer.getRequestQueue().enqueue(newEnergyRequest); + forwardRejectedMessage(rejectedMessage); + } + + /** + * Forwards an election message to the successor. + * + * @param electionMessage The election message. + */ + private void forwardElectionMessage(RingServiceOuterClass.ElectionMessage electionMessage) { + NetworkRepository networkRepository = peer.getNetworkRepository(); + String messageString = GrpcUtils.toString(electionMessage); + RingServiceGrpc.RingServiceStub successorStub = networkRepository.getSuccessorStub(); + BiConsumer> grpcCall = + successorStub != null ? successorStub::election : null; + StreamObserver streamObserver = getStreamObserverEmpty(messageString, networkRepository.getSuccessor()); + sendMessage(electionMessage, messageString, grpcCall, streamObserver); + } + + /** + * Forwards an elected message to the successor. + * + * @param electedMessage The elected message. + */ + private void forwardElectedMessage(RingServiceOuterClass.ElectedMessage electedMessage) { + NetworkRepository networkRepository = peer.getNetworkRepository(); + String messageString = GrpcUtils.toString(electedMessage); + RingServiceGrpc.RingServiceStub successorStub = networkRepository.getSuccessorStub(); + BiConsumer> grpcCall = + successorStub != null ? successorStub::elected : null; + StreamObserver streamObserver = getStreamObserverEmpty(messageString, networkRepository.getSuccessor()); + sendMessage(electedMessage, messageString, grpcCall, streamObserver); + } + + /** + * Forwards a rejected message to the successor. + * + * @param rejectedMessage The rejection message. + */ + private void forwardRejectedMessage(RingServiceOuterClass.RejectedMessage rejectedMessage) { + NetworkRepository networkRepository = peer.getNetworkRepository(); + String messageString = GrpcUtils.toString(rejectedMessage); + RingServiceGrpc.RingServiceStub successorStub = networkRepository.getSuccessorStub(); + BiConsumer> grpcCall = + successorStub != null ? successorStub::rejected : null; + StreamObserver streamObserver = getStreamObserverEmpty(messageString, networkRepository.getSuccessor()); + sendMessage(rejectedMessage, messageString, grpcCall, streamObserver); + } +} diff --git a/desm-network/src/main/java/org/example/desm/network/protocol/ElectionMessageFactory.java b/desm-network/src/main/java/org/example/desm/network/protocol/ElectionMessageFactory.java new file mode 100644 index 0000000..d0096c0 --- /dev/null +++ b/desm-network/src/main/java/org/example/desm/network/protocol/ElectionMessageFactory.java @@ -0,0 +1,32 @@ +package org.example.desm.network.protocol; + +import org.example.desm.grpc.RingServiceOuterClass; + +/** + * Factory class interface for creating a new {@link RingServiceOuterClass.ElectionMessage}. + */ +public interface ElectionMessageFactory { + /** + * Builds a new {@link RingServiceOuterClass.ElectionMessage} to participate + * in an election for the given energy request. + * + * @param energyRequest The energy request. + * @return A new ElectionMessage for participation in an election. + */ + RingServiceOuterClass.ElectionMessage buildParticipationElectionMessage( + RingServiceOuterClass.EnergyRequest energyRequest + ); + + /** + * Builds a new {@link RingServiceOuterClass.ElectionMessage} to participate + * in an election for the given energy request with the given bid price. + * + * @param energyRequest The energy request. + * @param bidPrice The bid price. + * @return A new ElectionMessage for participation in an election. + */ + RingServiceOuterClass.ElectionMessage buildParticipationElectionMessage( + RingServiceOuterClass.EnergyRequest energyRequest, + double bidPrice + ); +} diff --git a/desm-network/src/main/java/org/example/desm/network/protocol/GrpcUtils.java b/desm-network/src/main/java/org/example/desm/network/protocol/GrpcUtils.java new file mode 100644 index 0000000..bd60f3b --- /dev/null +++ b/desm-network/src/main/java/org/example/desm/network/protocol/GrpcUtils.java @@ -0,0 +1,95 @@ +package org.example.desm.network.protocol; + +import org.example.desm.grpc.RingServiceOuterClass; + +/** + * Utility class for gRPC messages. + */ +public class GrpcUtils { + /** + * Returns a string representation of a JoinRequest. + * + * @param joinRequest The join request. + * @return A string representation of the join request. + */ + public static String toString(RingServiceOuterClass.JoinRequest joinRequest) { + RingServiceOuterClass.Peer requester = joinRequest.getRequester(); + return String.format("JoinRequest(requester: %s, address: %s, port: %s)", + requester.getId(), requester.getAddress(), requester.getPort()); + } + + /** + * Returns a string representation of a WelcomeNotification. + * + * @param welcomeNotification The welcome notification. + * @return A string representation of the welcome notification. + */ + public static String toString(RingServiceOuterClass.WelcomeNotification welcomeNotification) { + RingServiceOuterClass.Peer successor = welcomeNotification.getSuccessor(); + return String.format("WelcomeNotification(successor: %s, address: %s, port: %s)", + successor.getId(), successor.getAddress(), successor.getPort()); + } + + /** + * Returns a string representation of a JoinedNotification. + * + * @param joinedNotification The joined notification. + * @return A string representation of the joined notification. + */ + public static String toString(RingServiceOuterClass.JoinedNotification joinedNotification) { + RingServiceOuterClass.Peer newPeer = joinedNotification.getNewPeer(); + return String.format("JoinedNotification(newPeer: %s, address: %s, port: %s)", + newPeer.getId(), newPeer.getAddress(), newPeer.getPort()); + } + + /** + * Returns a string representation of an EnergyRequest. + * + * @param energyRequest The energy request. + * @return A string representation of the energy request. + */ + public static String toString(RingServiceOuterClass.EnergyRequest energyRequest) { + return String.format("EnergyRequest(id: %s, kwh_power: %d, timestamp: %d, attempt_number: %d)", + energyRequest.getId(), + energyRequest.getKwhPower(), + energyRequest.getTimestamp(), + energyRequest.getAttemptNumber()); + } + + /** + * Returns a string representation of an ElectionMessage. + * + * @param electionMessage The election message. + * @return A string representation of the election message. + */ + public static String toString(RingServiceOuterClass.ElectionMessage electionMessage) { + return String.format("ElectionMessage(energy_request: %s, candidate_id: %s, candidate_bid_price: %f)", + GrpcUtils.toString(electionMessage.getEnergyRequest()), + electionMessage.getCandidateId(), + electionMessage.getCandidateBidPrice()); + } + + /** + * Returns a string representation of an ElectedMessage. + * + * @param electedMessage The elected message. + * @return A string representation of the elected message. + */ + public static String toString(RingServiceOuterClass.ElectedMessage electedMessage) { + return String.format("ElectedMessage(energy_request: %s, winner_id: %s, winner_bid_price: %f)", + GrpcUtils.toString(electedMessage.getEnergyRequest()), + electedMessage.getWinnerId(), + electedMessage.getWinnerBidPrice()); + } + + /** + * Returns a string representation of a RejectedMessage. + * + * @param rejectedMessage The rejected message. + * @return A string representation of the rejected message. + */ + public static String toString(RingServiceOuterClass.RejectedMessage rejectedMessage) { + return String.format("RejectionMessage(energy_request: %s, winner_id: %s)", + GrpcUtils.toString(rejectedMessage.getEnergyRequest()), rejectedMessage.getWinnerId()); + } +} diff --git a/desm-network/src/main/java/org/example/desm/network/protocol/NetworkManager.java b/desm-network/src/main/java/org/example/desm/network/protocol/NetworkManager.java new file mode 100644 index 0000000..9443518 --- /dev/null +++ b/desm-network/src/main/java/org/example/desm/network/protocol/NetworkManager.java @@ -0,0 +1,261 @@ +package org.example.desm.network.protocol; + +import com.google.protobuf.Empty; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.stub.StreamObserver; +import org.example.desm.common.dto.PowerPlant; +import org.example.desm.grpc.RingServiceGrpc; +import org.example.desm.grpc.RingServiceOuterClass; +import org.example.desm.network.PeerContext; +import org.example.desm.network.structure.NetworkRepository; +import org.example.desm.network.RingState; + +import java.util.function.BiConsumer; + +/** + * Manages the network protocol. + */ +public class NetworkManager extends ProtocolManager { + /** + * Creates a {@link NetworkManager}. + * + * @param peer The peer context. + */ + public NetworkManager(PeerContext peer) { + super(peer); + } + + /** + * Joins the ring, updating the successor and setting the state to {@link RingState#PARTICIPANT}. + * + * @param newSuccessor The new successor. + */ + public void joinRing(PowerPlant newSuccessor) { + peer.getNetworkRepository().setSuccessor(newSuccessor); + peer.setState(RingState.PARTICIPANT); + } + + /** + * Initiates a join request to enter the ring, contacting the target peer. + * + * @param targetPeer The peer to contact for joining. + * @return {@code true} if the join request was accepted, {@code false} otherwise. + */ + public boolean initJoinRequest(PowerPlant targetPeer) { + RingServiceOuterClass.Peer requester = RingServiceOuterClass.Peer.newBuilder() + .setId(peer.getId()) + .setAddress(peer.getAddress()) + .setPort(peer.getPort()) + .build(); + + RingServiceOuterClass.JoinRequest request = RingServiceOuterClass.JoinRequest.newBuilder() + .setRequester(requester) + .build(); + + ManagedChannel channel = ManagedChannelBuilder + .forAddress(targetPeer.getAddress(), targetPeer.getPort()) + .usePlaintext() + .build(); + + RingServiceGrpc.RingServiceBlockingStub stub = RingServiceGrpc.newBlockingStub(channel); + RingServiceOuterClass.JoinReply reply = stub.join(request); + channel.shutdownNow(); + + boolean accepted = reply.getAccepted(); + if (accepted) { + System.out.printf("[Network] Successfully sent JoinRequest to peer %s.%n", targetPeer.getId()); + } else { + System.err.printf("[Network] JoinRequest to peer %s was rejected, he is not already in the ring.%n", + targetPeer.getId()); + } + + return accepted; + } + + /** + * Processes a {@link RingServiceOuterClass.JoinRequest} from a peer. + *

    + * If the new node should be inserted as successor of the current node, it does so, and + * sends a WelcomeNotification to the new successor, otherwise it forwards the request to the + * successor. + * + * @param joinRequest The join request. + */ + public void processJoinRequest(RingServiceOuterClass.JoinRequest joinRequest) { + RingServiceOuterClass.Peer requester = joinRequest.getRequester(); + NetworkRepository networkRepository = peer.getNetworkRepository(); + PowerPlant oldSuccessor = networkRepository.getSuccessor(); + + if (shouldInsertJoinRequesterPeerHere(requester)) { + System.out.printf("[Network] Inserting peer %s between current node %s and successor %s.%n", + requester.getId(), peer.getId(), oldSuccessor); + // Update our successor with the new peer + networkRepository.setSuccessor(new PowerPlant( + requester.getId(), + requester.getAddress(), + requester.getPort() + )); + sendWelcomeToUpdatedSuccessor(oldSuccessor); + } else { + System.out.printf("[Network] Forwarding %s to successor %s.%n", + GrpcUtils.toString(joinRequest), oldSuccessor); + forwardJoinRequest(joinRequest); + } + } + + /** + * Processes a {@link RingServiceOuterClass.WelcomeNotification} from a peer. + *

    + * Updates the successor of the current node to the notified peer, + * and changes the state to {@link RingState#PARTICIPANT}. + * Then starts the joined notification round-ring process. + * + * @param welcomeNotification The welcome notification. + */ + public void processWelcomeNotification(RingServiceOuterClass.WelcomeNotification welcomeNotification) { + PowerPlant newSuccessor = new PowerPlant( + welcomeNotification.getSuccessor().getId(), + welcomeNotification.getSuccessor().getAddress(), + welcomeNotification.getSuccessor().getPort() + ); + + joinRing(newSuccessor); + System.out.printf("[Network] Join completed! New successor: %s%n", newSuccessor); + + RingServiceOuterClass.Peer currentPeer = RingServiceOuterClass.Peer.newBuilder() + .setId(peer.getId()) + .setAddress(peer.getAddress()) + .setPort(peer.getPort()) + .build(); + + RingServiceOuterClass.JoinedNotification notification = RingServiceOuterClass.JoinedNotification.newBuilder() + .setNewPeer(currentPeer) + .build(); + + forwardJoinedNotification(notification); + } + + /** + * Processes a {@link RingServiceOuterClass.JoinedNotification} from a peer. + *

    + * Adds the notified peer to the ring, and forwards the notification to the successor. + * If the notification is about ourselves, the round-ring process is complete. + * + * @param joinedNotification The joined notification. + */ + public void processJoinedNotification(RingServiceOuterClass.JoinedNotification joinedNotification) { + RingServiceOuterClass.Peer newPeer = joinedNotification.getNewPeer(); + + // If the notification is about ourselves, the process is complete + if (peer.getId().equals(newPeer.getId())) { + System.out.printf("[Network] %s completed the round of ring.%n", GrpcUtils.toString(joinedNotification)); + return; + } + + // Add the peer to the local network ring view + peer.getNetworkRepository().add(new PowerPlant( + newPeer.getId(), + newPeer.getAddress(), + newPeer.getPort() + )); + + forwardJoinedNotification(joinedNotification); + } + + /** + * Determines if the peer requesting to join should be inserted between the current node and its successor. + * + * @param joinRequester The peer requesting to join + * @return {@code true} if the peer should be inserted here, {@code false} if it should be forwarded + */ + private boolean shouldInsertJoinRequesterPeerHere(RingServiceOuterClass.Peer joinRequester) { + NetworkRepository networkRepository = peer.getNetworkRepository(); + PowerPlant current = peer.getCurrentPowerPlant(); + PowerPlant successor = networkRepository.getSuccessor(); + if (networkRepository.hasInvalidSuccessor() || current.equals(successor)) { + return true; + } + + String currentId = current.getId(); + String successorId = successor.getId(); + String newId = joinRequester.getId(); + + // Case 1: Normal case + // [currentId=1] --> newId=2 --> [successorId=3] + boolean isNewIdGreater = currentId.compareTo(successorId) < 0 && + (newId.compareTo(currentId) > 0 && newId.compareTo(successorId) < 0); + + // Case 2: Wrap-around case + // [currentId=8] --> newId=9 --> [successorId=1] + // [currentId=8] --> newId=1 --> [successorId=2] + boolean isNewIdInRingClosure = currentId.compareTo(successorId) > 0 && + (newId.compareTo(currentId) > 0 || newId.compareTo(successorId) < 0); + + return isNewIdGreater || isNewIdInRingClosure; + } + + /** + * Send a Welcome notification to the peer requesting to join, + * that is the newly updated successor of the current node. + * + * @param oldSuccessor The old successor of the current node + */ + private void sendWelcomeToUpdatedSuccessor(PowerPlant oldSuccessor) { + // Single peer case in the ring + // If we don't have a successor, the current peer becomes the successor of the new peer + // becomes [A] -> [B] and [B] -> [A] with a bidirectional connection through the two peers + if (oldSuccessor == null) { + oldSuccessor = peer.getCurrentPowerPlant(); + } + + RingServiceOuterClass.Peer joinRequesterSuccessor = RingServiceOuterClass.Peer.newBuilder() + .setId(oldSuccessor.getId()) + .setAddress(oldSuccessor.getAddress()) + .setPort(oldSuccessor.getPort()) + .build(); + + RingServiceOuterClass.WelcomeNotification welcome = RingServiceOuterClass.WelcomeNotification.newBuilder() + .setSuccessor(joinRequesterSuccessor) + .build(); + + NetworkRepository networkRepository = peer.getNetworkRepository(); + String messageString = GrpcUtils.toString(welcome); + RingServiceGrpc.RingServiceStub successorStub = networkRepository.getSuccessorStub(); + BiConsumer> grpcCall = + successorStub != null ? successorStub::welcome : null; + StreamObserver streamObserver = getStreamObserverEmpty(messageString, networkRepository.getSuccessor()); + sendMessage(welcome, messageString, grpcCall, streamObserver); + } + + /** + * Forwards the join request to the successor. + * + * @param joinRequest The join request. + */ + private void forwardJoinRequest(RingServiceOuterClass.JoinRequest joinRequest) { + NetworkRepository networkRepository = peer.getNetworkRepository(); + String messageString = GrpcUtils.toString(joinRequest); + RingServiceGrpc.RingServiceStub successorStub = networkRepository.getSuccessorStub(); + BiConsumer> grpcCall = + successorStub != null ? successorStub::join : null; + StreamObserver streamObserver = + getStreamObserverJoinReply(messageString, networkRepository.getSuccessor()); + sendMessage(joinRequest, messageString, grpcCall, streamObserver); + } + + /** + * Forwards the joined notification to the successor. + * + * @param joinedNotification The joined notification. + */ + private void forwardJoinedNotification(RingServiceOuterClass.JoinedNotification joinedNotification) { + NetworkRepository networkRepository = peer.getNetworkRepository(); + String messageString = GrpcUtils.toString(joinedNotification); + RingServiceGrpc.RingServiceStub successorStub = networkRepository.getSuccessorStub(); + BiConsumer> grpcCall = + successorStub != null ? successorStub::joined : null; + StreamObserver streamObserver = getStreamObserverEmpty(messageString, networkRepository.getSuccessor()); + sendMessage(joinedNotification, messageString, grpcCall, streamObserver); + } +} diff --git a/desm-network/src/main/java/org/example/desm/network/protocol/ProtocolManager.java b/desm-network/src/main/java/org/example/desm/network/protocol/ProtocolManager.java new file mode 100644 index 0000000..830016c --- /dev/null +++ b/desm-network/src/main/java/org/example/desm/network/protocol/ProtocolManager.java @@ -0,0 +1,145 @@ +package org.example.desm.network.protocol; + +import com.google.protobuf.Empty; +import io.grpc.Context; +import io.grpc.stub.StreamObserver; +import org.example.desm.common.dto.PowerPlant; +import org.example.desm.grpc.RingServiceOuterClass; +import org.example.desm.network.PeerContext; +import org.example.desm.network.structure.NetworkRepository; + +import java.util.function.BiConsumer; + +/** + * Abstract class that provides common methods for sending messages to the successor node. + */ +public abstract class ProtocolManager { + protected final PeerContext peer; + + /** + * Constructor for the {@link ProtocolManager} class. + * + * @param peer The peer context. + */ + public ProtocolManager(PeerContext peer) { + this.peer = peer; + } + + /** + * Common method that sends a message to the successor node. + *

    + * If the successor does not exist, the method logs an error message and returns. + * Otherwise, the method calls the provided grpcCall method with + * the given message and {@link StreamObserver}. + * + * @param message The message to forward. + * @param messageString The string representation of the message. + * @param grpcCall The method to call on the successor node. + * @param streamObserver The stream observer to use for the call. + */ + public void sendMessage( + T message, + String messageString, + BiConsumer> grpcCall, + StreamObserver streamObserver + ) { + NetworkRepository networkRepository = peer.getNetworkRepository(); + if (networkRepository.hasInvalidSuccessor()) { + System.err.printf("[gRPC] Cannot forward %s: no successor available%n", messageString); + return; + } + + if (grpcCall != null && streamObserver != null) { + Context.current().fork().run(() -> grpcCall.accept(message, streamObserver)); + } + } + + /** + * Returns a {@link StreamObserver} for a {@link RingServiceOuterClass.JoinReply}. + * + * @param messageString The string representation of the message. + * @param successor The successor node. + * @return A StreamObserver for a JoinReply. + */ + public StreamObserver getStreamObserverJoinReply( + String messageString, + PowerPlant successor + ) { + return new io.grpc.stub.StreamObserver<>() { + @Override + public void onNext(RingServiceOuterClass.JoinReply value) { + // Contacting the guaranteed successor in the ring, which should accept the request. + // The reply value is only relevant to the joining peer that initiated the JoinRequest, via a blocking call. + } + + @Override + public void onError(Throwable t) { + handleSendError(messageString, successor, t); + } + + @Override + public void onCompleted() { + handleSendCompletion(messageString, successor); + } + }; + } + + /** + * Returns a {@link StreamObserver} for an {@link Empty} message. + * + * @param messageString The string representation of the message. + * @param successor The successor node. + * @return A StreamObserver for an Empty message. + */ + public StreamObserver getStreamObserverEmpty( + String messageString, + PowerPlant successor + ) { + return new io.grpc.stub.StreamObserver<>() { + @Override + public void onNext(Empty value) { + // Empty response, nothing to do here + } + + @Override + public void onError(Throwable t) { + handleSendError(messageString, successor, t); + } + + @Override + public void onCompleted() { + handleSendCompletion(messageString, successor); + } + }; + } + + /** + * Handles an error when sending a message to the successor node. + * + * @param messageString The string representation of the message. + * @param successor The successor node. + * @param t The error. + */ + private void handleSendError( + String messageString, + PowerPlant successor, + Throwable t + ) { + System.err.printf("[gRPC] Error forwarding %s to successor %s: %s%n", + messageString, successor, t.getMessage()); + } + + /** + * Handles the completion of a message sending to the successor node. + * + * @param messageString The string representation of the message. + * @param successor The successor node. + */ + private void handleSendCompletion( + String messageString, + PowerPlant successor + ) { + System.out.printf("[gRPC] Completed forwarding %s to successor %s.%n", + messageString, successor); + } +} diff --git a/desm-network/src/main/java/org/example/desm/network/protocol/RingService.java b/desm-network/src/main/java/org/example/desm/network/protocol/RingService.java new file mode 100644 index 0000000..3bb211a --- /dev/null +++ b/desm-network/src/main/java/org/example/desm/network/protocol/RingService.java @@ -0,0 +1,154 @@ +package org.example.desm.network.protocol; + +import com.google.protobuf.Empty; +import io.grpc.stub.StreamObserver; +import org.example.desm.grpc.RingServiceGrpc; +import org.example.desm.grpc.RingServiceOuterClass; +import org.example.desm.network.PeerContext; + +/** + * Extends the {@link RingServiceGrpc.RingServiceImplBase} class to implement + * the gRPC methods of the RingService interface. + */ +public class RingService extends RingServiceGrpc.RingServiceImplBase { + private final PeerContext peer; + + /** + * Creates a {@link RingService}. + * + * @param peer the peer context + */ + public RingService(PeerContext peer) { + this.peer = peer; + } + + @Override + public void join( + RingServiceOuterClass.JoinRequest joinRequest, + StreamObserver responseObserver + ) { + try { + peer.getPeerLock().acquireNetwork(); + boolean isParticipant = peer.isParticipant(); + + // If the current node is only registered but not yet in the ring, reject the JoinRequest + RingServiceOuterClass.JoinReply reply = RingServiceOuterClass.JoinReply.newBuilder() + .setAccepted(isParticipant) + .build(); + + responseObserver.onNext(reply); + responseObserver.onCompleted(); + + if (!reply.getAccepted()) { + System.out.printf("[gRPC] Rejected %s: the current node %s is not yet in the ring.%n", + GrpcUtils.toString(joinRequest), peer.getId()); + } else { + System.out.printf("[gRPC] Process %s%n", GrpcUtils.toString(joinRequest)); + peer.getNetworkManager().processJoinRequest(joinRequest); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + responseObserver.onError(e); + System.err.println("Acquire lock during Join interrupted."); + } finally { + peer.getPeerLock().releaseNetwork(); + } + } + + @Override + public void welcome( + RingServiceOuterClass.WelcomeNotification welcomeNotification, + StreamObserver responseObserver + ) { + try { + peer.getPeerLock().acquireNetwork(); + responseObserver.onNext(Empty.getDefaultInstance()); + responseObserver.onCompleted(); + System.out.printf("[gRPC] Process %s%n", GrpcUtils.toString(welcomeNotification)); + peer.getNetworkManager().processWelcomeNotification(welcomeNotification); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + responseObserver.onError(e); + System.err.println("Acquire lock during Welcome interrupted."); + } finally { + peer.getPeerLock().releaseNetwork(); + } + } + + @Override + public void joined( + RingServiceOuterClass.JoinedNotification joinedNotification, + StreamObserver responseObserver + ) { + try { + peer.getPeerLock().acquireNetwork(); + responseObserver.onNext(Empty.getDefaultInstance()); + responseObserver.onCompleted(); + System.out.printf("[gRPC] Process %s%n", GrpcUtils.toString(joinedNotification)); + peer.getNetworkManager().processJoinedNotification(joinedNotification); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + responseObserver.onError(e); + System.err.println("Acquire lock during Joined interrupted."); + } finally { + peer.getPeerLock().releaseNetwork(); + } + } + + @Override + public void election( + RingServiceOuterClass.ElectionMessage electionMessage, + StreamObserver responseObserver + ) { + try { + peer.getPeerLock().acquireElection(); + responseObserver.onNext(Empty.getDefaultInstance()); + responseObserver.onCompleted(); + System.out.printf("[gRPC] Process %s%n", GrpcUtils.toString(electionMessage)); + peer.getElectionManager().processElectionMessage(electionMessage); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + responseObserver.onError(e); + System.err.println("Acquire lock during Election interrupted."); + } finally { + peer.getPeerLock().releaseElection(); + } + } + + @Override + public void elected( + RingServiceOuterClass.ElectedMessage electedMessage, + StreamObserver responseObserver + ) { + try { + peer.getPeerLock().acquireElection(); + responseObserver.onNext(Empty.getDefaultInstance()); + responseObserver.onCompleted(); + System.out.printf("[gRPC] Process %s%n", GrpcUtils.toString(electedMessage)); + peer.getElectionManager().processElectedMessage(electedMessage); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + responseObserver.onError(e); + System.err.println("Acquire lock during Elected interrupted."); + } finally { + peer.getPeerLock().releaseElection(); + } + } + + @Override + public void rejected(RingServiceOuterClass.RejectedMessage rejectedMessage, StreamObserver responseObserver) { + try { + peer.getPeerLock().acquireElection(); + responseObserver.onNext(Empty.getDefaultInstance()); + responseObserver.onCompleted(); + System.out.printf("[gRPC] Process %s%n", GrpcUtils.toString(rejectedMessage)); + peer.getElectionManager().processRejectedMessage(rejectedMessage); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + responseObserver.onError(e); + System.err.println("Acquire lock during Rejected interrupted."); + } finally { + peer.getPeerLock().releaseElection(); + } + } +} diff --git a/desm-network/src/main/java/org/example/desm/network/structure/Buffer.java b/desm-network/src/main/java/org/example/desm/network/structure/Buffer.java new file mode 100644 index 0000000..1a84c77 --- /dev/null +++ b/desm-network/src/main/java/org/example/desm/network/structure/Buffer.java @@ -0,0 +1,10 @@ +package org.example.desm.network.structure; + +import org.example.desm.common.dto.Measurement; + +import java.util.List; + +public interface Buffer { + void addMeasurement(Measurement m); + List readAllAndClean(); +} diff --git a/desm-network/src/main/java/org/example/desm/network/structure/BusyMonitor.java b/desm-network/src/main/java/org/example/desm/network/structure/BusyMonitor.java new file mode 100644 index 0000000..beba769 --- /dev/null +++ b/desm-network/src/main/java/org/example/desm/network/structure/BusyMonitor.java @@ -0,0 +1,45 @@ +package org.example.desm.network.structure; + +/** + * Class for monitoring the busy status of a power plant. + */ +public class BusyMonitor { + private boolean isBusy = false; + + /** + * Checks if the plant is currently busy. + * + * @return true if busy, false otherwise. + */ + public synchronized boolean isBusy() { + return isBusy; + } + + /** + * Set the plant's busy status. + * If setting to false, notifies all waiting threads. + * + * @param busy true if the plant is busy, false otherwise. + */ + public synchronized void setBusy(boolean busy) { + System.out.println("[Production] Setting busy to: " + busy); + this.isBusy = busy; + // If the plant is not busy, notify all waiting threads + if (!busy) { + notifyAll(); + } + } + + /** + * Waits until the plant is no longer busy. + * If the plant is already not busy, returns immediately. + * This method should be called within a loop to handle spurious wakeups. + * + * @throws InterruptedException if the thread is interrupted while waiting. + */ + public synchronized void waitUntilNotBusy() throws InterruptedException { + while (isBusy) { + wait(); + } + } +} diff --git a/desm-network/src/main/java/org/example/desm/network/structure/ElectionMessageComparator.java b/desm-network/src/main/java/org/example/desm/network/structure/ElectionMessageComparator.java new file mode 100644 index 0000000..1eba5c8 --- /dev/null +++ b/desm-network/src/main/java/org/example/desm/network/structure/ElectionMessageComparator.java @@ -0,0 +1,35 @@ +package org.example.desm.network.structure; + +import org.example.desm.grpc.RingServiceOuterClass; + +import java.util.Comparator; + +/** + * Comparator for comparing {@link RingServiceOuterClass.ElectionMessage} instances. + *

    + * The comparison is based first on the candidate's bid price: the message with the lower bid price wins. + * If the bid prices are equal, the message with the lexicographically greater candidate ID wins. + */ +public class ElectionMessageComparator implements Comparator { + /** + * Compares two {@link RingServiceOuterClass.ElectionMessage} instances to determine which one wins. + *

    + * Assumes that candidate IDs are unique, so the method will never return {@code 0}. + * + * @param m1 the first ElectionMessage to compare + * @param m2 the second ElectionMessage to compare + * @return the value {@code -1} if m1 is preferred over m2 (m1 wins); + * the value {@code 1} if m2 is preferred over m1 (m2 wins). + */ + @Override + public int compare(RingServiceOuterClass.ElectionMessage m1, RingServiceOuterClass.ElectionMessage m2) { + // First compare bid prices (lower is better) + int bidComparison = Double.compare(m1.getCandidateBidPrice(), m2.getCandidateBidPrice()); + if (bidComparison != 0) { + return bidComparison; + } + + // Bid prices equal, compare candidate IDs (greater is better) + return m2.getCandidateId().compareTo(m1.getCandidateId()); + } +} \ No newline at end of file diff --git a/desm-network/src/main/java/org/example/desm/network/structure/ElectionRepository.java b/desm-network/src/main/java/org/example/desm/network/structure/ElectionRepository.java new file mode 100644 index 0000000..1c733f2 --- /dev/null +++ b/desm-network/src/main/java/org/example/desm/network/structure/ElectionRepository.java @@ -0,0 +1,188 @@ +package org.example.desm.network.structure; + +import org.example.desm.grpc.RingServiceOuterClass; +import org.example.desm.network.protocol.ElectionMessageFactory; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * A thread-safe repository as a data structure to store information about ongoing and completed elections. + */ +public class ElectionRepository { + private final String currentPlantId; + + /** + * If an election about a {@link RingServiceOuterClass.EnergyRequest} is completed, it is stored the original + * election's request here. + *

    + * The key is the request ID {@link RingServiceOuterClass.EnergyRequest#getId}, the value is the + * {@link RingServiceOuterClass.ElectedMessage}. + */ + private final Map completedElections = new HashMap<>(); + + /** + * If an election is ongoing, it stores our local propose here, and it means that the peer is participating to it. + *

    + * The key is the request ID {@link RingServiceOuterClass.EnergyRequest#getId}, the value is our + * {@link RingServiceOuterClass.ElectionMessage} created when we have participated in the election. + */ + private final Map ongoingElections = new HashMap<>(); + + /** + * It stores the last attempt number seen by the peer for a given {@link RingServiceOuterClass.EnergyRequest} + * for an ongoing election. + *

    + * The key is the request ID {@link RingServiceOuterClass.EnergyRequest#getId}, the value is the last + * attempt number seen. + */ + private final Map lastAttemptNumberSeen = new HashMap<>(); + + private RingServiceOuterClass.ElectedMessage lastElectionWon = null; + + /** + * Creates a new {@link ElectionRepository}. + * + * @param currentPlantId The current power plant ID. + */ + public ElectionRepository(String currentPlantId) { + this.currentPlantId = currentPlantId; + } + + public synchronized Collection getCompletedElections() { + return new ArrayList<>(completedElections.values()); + } + + /** + * Returns all the {@link RingServiceOuterClass.ElectedMessage} won by the peer with the current peer. + * + * @return A collection of {@link RingServiceOuterClass.ElectedMessage} won by the peer with the given ID. + */ + public synchronized Collection getElectionsWon() { + List electionsWon = new ArrayList<>(); + for (RingServiceOuterClass.ElectedMessage elected : completedElections.values()) { + if (elected.getWinnerId().equals(currentPlantId)) { + electionsWon.add(elected); + } + } + return electionsWon; + } + + public synchronized RingServiceOuterClass.ElectedMessage getLastElectionWon() { + return lastElectionWon; + } + + /** + * Checks if an election for the given {@link RingServiceOuterClass.EnergyRequest} is already completed. + * + * @param energyRequest The energy request. + * @return {@code true} if the election is completed, {@code false} otherwise. + */ + public synchronized boolean alreadyCompleted(RingServiceOuterClass.EnergyRequest energyRequest) { + String requestId = energyRequest.getId(); + return completedElections.containsKey(requestId); + } + + /** + * Checks if we are already participating in an election for a given {@link RingServiceOuterClass.EnergyRequest}. + *

    + * We are participant in an election if it has an ongoing election for the same request ID and + * the attempt number is the same as the one in the election message. + * + * @param energyRequest The energy request. + * @return {@code true} if the election is ongoing, {@code false} otherwise. + */ + public synchronized boolean alreadyParticipant(RingServiceOuterClass.EnergyRequest energyRequest) { + String requestId = energyRequest.getId(); + if (ongoingElections.containsKey(requestId)) { + RingServiceOuterClass.EnergyRequest currentEnergyRequest = + ongoingElections.get(requestId).getEnergyRequest(); + return currentEnergyRequest.getAttemptNumber() == energyRequest.getAttemptNumber(); + } + return false; + } + + /** + * Update the last attempt number known for a given {@link RingServiceOuterClass.EnergyRequest}. + *

    + * If we haven't seen this request before, or if the received attempt equals or is more recent, it's valid + * and update it. Otherwise, if the received attempt is older, it's not valid. + * + * @param energyRequest The energy request. + * @return {@code true} if the last seen attempt number is updated, so the received request is more recent + * or equal to the last seen one, {@code false} otherwise + */ + public synchronized boolean validateAndRecordAttemptNumber( + RingServiceOuterClass.EnergyRequest energyRequest + ) { + String requestId = energyRequest.getId(); + int attemptNumber = energyRequest.getAttemptNumber(); + if (!lastAttemptNumberSeen.containsKey(requestId) || + lastAttemptNumberSeen.get(requestId) <= attemptNumber) { + lastAttemptNumberSeen.put(requestId, attemptNumber); + return true; + } + return false; + } + + /** + * Participates in an election for the given {@link RingServiceOuterClass.EnergyRequest} if it's not already. + *

    + * If participating already in an election for the same request ID and the attempt number is the same as the one + * in the energy request, it returns the existing election message. + * Otherwise, it builds a new election message and returns it. + *

    + * If an older election is ongoing for the same request ID, but with an older attempt number, + * it will take the bid price from the older election. + * + * @param energyRequest The energy request. + * @param factory The participation election message factory. + * @return The built election message or the existing election message. + */ + public synchronized RingServiceOuterClass.ElectionMessage participateInElection( + RingServiceOuterClass.EnergyRequest energyRequest, + ElectionMessageFactory factory + ) { + // At this point, updateLastAttemptNumber should have been called already and guaranteed + // that energyRequest.getAttemptNumber() is the most recent or equal to the last seen one. + + String requestId = energyRequest.getId(); + if (alreadyParticipant(energyRequest)) { + return ongoingElections.get(requestId); + } + + RingServiceOuterClass.ElectionMessage oldElection = ongoingElections.get(requestId); + RingServiceOuterClass.ElectionMessage currentElection; + if (oldElection == null) { + currentElection = factory.buildParticipationElectionMessage(energyRequest); + } else { + currentElection = factory.buildParticipationElectionMessage( + energyRequest, oldElection.getCandidateBidPrice()); + } + ongoingElections.put(requestId, currentElection); + + return currentElection; + } + + /** + * Completes an election for the given {@link RingServiceOuterClass.ElectedMessage}. + *

    + * If the winner is the current peer, it updates the last election won. + * + * @param electedMessage The elected message. + */ + public synchronized void completeElection( + RingServiceOuterClass.ElectedMessage electedMessage + ) { + String requestId = electedMessage.getEnergyRequest().getId(); + ongoingElections.remove(requestId); + lastAttemptNumberSeen.remove(requestId); + completedElections.put(requestId, electedMessage); + if (electedMessage.getWinnerId().equals(currentPlantId)) { + lastElectionWon = electedMessage; + } + } +} diff --git a/desm-network/src/main/java/org/example/desm/network/structure/EnergyRequestQueue.java b/desm-network/src/main/java/org/example/desm/network/structure/EnergyRequestQueue.java new file mode 100644 index 0000000..b0eb745 --- /dev/null +++ b/desm-network/src/main/java/org/example/desm/network/structure/EnergyRequestQueue.java @@ -0,0 +1,82 @@ +package org.example.desm.network.structure; + +import org.example.desm.grpc.RingServiceOuterClass; +import org.example.desm.network.protocol.GrpcUtils; + +import java.util.Comparator; +import java.util.PriorityQueue; +import java.util.Queue; + +/** + * Thread-safe queue for managing energy requests using producer-consumer pattern. + * The MQTT callback acts as producer, the election manager as consumer. + */ +public class EnergyRequestQueue { + private volatile boolean closed = false; + + /** + * A {@link PriorityQueue} queue to store {@link RingServiceOuterClass.EnergyRequest} with priority based on timestamp. + */ + private final Queue queue = new PriorityQueue<>( + Comparator.comparingLong(RingServiceOuterClass.EnergyRequest::getTimestamp) + ); + + /** + * Adds an energy request to the queue (Producer operation). + * + * @param request the energy request to add. + */ + public synchronized void enqueue(RingServiceOuterClass.EnergyRequest request) { + if (closed) { + System.out.printf("[Queue] Cannot enqueue %s, queue is closed.%n", + GrpcUtils.toString(request)); + return; + } + + queue.offer(request); + System.out.printf("[Queue] Produced: %s [queue size: %d]%n", + GrpcUtils.toString(request), queue.size()); + notify(); + } + + /** + * Removes and returns an energy request from the queue (Consumer operation). + * Blocks if the queue is empty until a request is available. + * + * @return the next energy request. + * @throws InterruptedException if the thread is interrupted while waiting. + */ + public synchronized RingServiceOuterClass.EnergyRequest dequeue() throws InterruptedException { + while (queue.isEmpty()) { + if (closed) { + return null; + } + wait(); + } + + RingServiceOuterClass.EnergyRequest request = queue.poll(); + if (request != null) { + System.out.printf("[Queue] Consumed: %s [queue size: %d]%n", + GrpcUtils.toString(request), queue.size()); + } + return request; + } + + /** + * Returns the number of elements in the queue. + * + * @return the size of the queue. + */ + public int size() { + return queue.size(); + } + + /** + * Closes the queue, preventing further enqueues and unblocking any waiting dequeue calls. + */ + public synchronized void close() { + this.closed = true; + notifyAll(); + System.out.println("[Queue] Queue has been closed!"); + } +} diff --git a/desm-network/src/main/java/org/example/desm/network/structure/NetworkRepository.java b/desm-network/src/main/java/org/example/desm/network/structure/NetworkRepository.java new file mode 100644 index 0000000..f8457ea --- /dev/null +++ b/desm-network/src/main/java/org/example/desm/network/structure/NetworkRepository.java @@ -0,0 +1,222 @@ +package org.example.desm.network.structure; + +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import org.example.desm.common.dto.PowerPlant; +import org.example.desm.grpc.RingServiceGrpc; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.concurrent.TimeUnit; + +/** + * A thread-safe repository as a data structure to store the network of power plants + * in a ring topology ordered by their IDs. It maintains a set of power plants, the + * current power plant, and its successor. + */ +public class NetworkRepository { + /** + * It stores the network's participants of the ring in a map. + * This map is ordered, so the power plants are sorted by their IDs. + */ + private final Map networkView = new TreeMap<>(); + private final PowerPlant current; + + private PowerPlant successor = null; + private ManagedChannel successorChannel = null; + private RingServiceGrpc.RingServiceStub successorStub = null; + + /** + * Creates a new network with the current power plant. + * + * @param current the current power plant. + */ + public NetworkRepository(PowerPlant current) { + this.current = current; + } + + /** + * Returns the network's participants. + * + * @return the network's participants {@link Collection}. + */ + public synchronized Collection getParticipants() { + return new ArrayList<>(networkView.values()); + } + + /** + * Returns the {@link PowerPlant} successor of the current peer. + * + * @return the successor. + */ + public synchronized PowerPlant getSuccessor() { + return successor; + } + + /** + * Returns the {@link ManagedChannel} communication channel to the successor + * of the current peer. + * + * @return the communication channel. + */ + public synchronized ManagedChannel getSuccessorChannel() { + return successorChannel; + } + + /** + * Returns the {@link RingServiceGrpc.RingServiceStub} stub of the successor + * of the current peer. + * + * @return the stub. + */ + public synchronized RingServiceGrpc.RingServiceStub getSuccessorStub() { + return successorStub; + } + + /** + * Updates the new {@link PowerPlant} successor, creates a {@link ManagedChannelBuilder} + * communication channel and a new {@link RingServiceGrpc.RingServiceStub} stub to it. + * + * @param newSuccessor the new successor + */ + public synchronized void setSuccessor(PowerPlant newSuccessor) { + if (newSuccessor == null) { + cleanupSuccessor(); + return; + } + + if (newSuccessor.equals(successor)) { + return; + } + + // Add the new successor to the network + add(newSuccessor); + cleanupSuccessor(); + + successor = newSuccessor; + successorChannel = ManagedChannelBuilder + .forAddress(successor.getAddress(), successor.getPort()) + .usePlaintext() + .build(); + successorStub = RingServiceGrpc.newStub(successorChannel); + } + + /** + * Checks if the current peer has an invalid successor. + * + * @return {@code true} if the current peer has an invalid successor, {@code false} otherwise. + */ + public synchronized boolean hasInvalidSuccessor() { + return successor == null || + successorChannel == null || + successorStub == null; + } + + /** + * Shuts down the channel of the successor node. + * + * @throws InterruptedException if the thread is interrupted while shutting down the channel. + */ + public synchronized void shutdownSuccessorChannel() throws InterruptedException { + if (successorChannel == null || successorChannel.isShutdown()) { + return; + } + + System.out.println("[gRPC] Closing successor channel of: " + successor); + successorChannel.shutdown(); + if (!successorChannel.awaitTermination(10, TimeUnit.SECONDS)) { + System.err.println("[gRPC] Channel did not terminate in time."); + } + } + + /** + * Checks if there is someone else in the network than the current power plant. + * + * @return {@code true} if there is someone else in the network, {@code false} otherwise. + */ + public synchronized boolean thereIsSomeoneElse() { + int size = networkView.size(); + if (current == null) { + return size > 0; + } else { + String currentId = current.getId(); + return networkView.containsKey(currentId) ? size > 1 : size > 0; + } + } + + /** + * Adds a new {@link PowerPlant} to the network. + * + * @param powerPlant the power plant to add. + */ + public synchronized void add(PowerPlant powerPlant) { + if (powerPlant == null) { + return; + } + + networkView.put(powerPlant.getId(), powerPlant); + } + + /** + * Adds a collection of {@link PowerPlant} to the network. + * + * @param powerPlants the collection of power plants to add. + */ + public synchronized void addAll(Collection powerPlants) { + powerPlants.stream() + .filter(Objects::nonNull) + .forEach(plant -> networkView.put(plant.getId(), plant)); + } + + /** + * Returns the predecessors {@link List} of the current power plant in the network + * considering the ring topology. + *

    + * E.g. ring: [1, 2, 3, 4, 5], current: 3, result: [2, 1, 5, 4] + * + * @return the predecessors list. + */ + public synchronized List getPredecessorsChain() { + if (networkView.isEmpty() || !thereIsSomeoneElse()) { + return Collections.emptyList(); + } + + List allPlants = new ArrayList<>(networkView.values()); + int currentIndex = allPlants.indexOf(current); + + List predecessors = new ArrayList<>(); + + // Add plants before current in reverse order + for (int i = currentIndex - 1; i >= 0; i--) { + predecessors.add(allPlants.get(i)); + } + + // Add plants after current in reverse order + for (int i = allPlants.size() - 1; i > currentIndex; i--) { + predecessors.add(allPlants.get(i)); + } + + return predecessors; + } + + /** + * Gracefully shuts down the current successor's channel, if it exists. + */ + private void cleanupSuccessor() { + try { + shutdownSuccessorChannel(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + System.err.println("[gRPC] Interrupted while shutting down the channel."); + } finally { + successor = null; + successorChannel = null; + successorStub = null; + } + } +} diff --git a/desm-network/src/main/java/org/example/desm/network/structure/PeerLock.java b/desm-network/src/main/java/org/example/desm/network/structure/PeerLock.java new file mode 100644 index 0000000..75b3f48 --- /dev/null +++ b/desm-network/src/main/java/org/example/desm/network/structure/PeerLock.java @@ -0,0 +1,79 @@ +package org.example.desm.network.structure; + +/** + * Used by peers in the network to coordinate network and election operations for a specific peer. + * It ensures that these operations never overlap, and gives priority to network operations. + *

    + * It enforces mutual exclusion between these two operations and gives priority to peers attempting + * network operations on the ring through the current peer. However, to avoid starvation of election + * requests, a fairness policy is implemented. + */ +public class PeerLock { + /** + * This constant determines the maximum number of consecutive network operations that + * can be granted before allowing at least one election operation. + * This prevents starvation of election operations if network operations arrive continuously. + */ + public static final int MAX_CONSECUTIVE_NETWORKS = 3; + + private int networksOpWaiting = 0; + private int networksOpSinceLastElection = 0; + private boolean networkInProgress = false; + private boolean electionOpInProgress = false; + + /** + * Acquires the lock to start an election operation. Blocks until: + *

      + *
    • No network operation is currently in progress.
    • + *
    • No other election operation is currently in progress.
    • + *
    • Either no network operation are waiting or the maximum consecutive network operation has been reached.
    • + *
    + * + * @throws InterruptedException if the thread is interrupted while waiting + */ + public synchronized void acquireElection() throws InterruptedException { + while (electionOpInProgress || networkInProgress || + (networksOpWaiting > 0 && networksOpSinceLastElection < MAX_CONSECUTIVE_NETWORKS)) { + wait(); + } + + networksOpSinceLastElection = 0; + electionOpInProgress = true; + } + + /** + * Releases the election operation lock and notifies all waiting threads. + */ + public synchronized void releaseElection() { + electionOpInProgress = false; + notifyAll(); + } + + /** + * Acquires the lock to perform a network operation (join, welcome, quit ecc.). Blocks until: + *
      + *
    • No election operation is currently in progress.
    • + *
    • No other network operation is currently in progress.
    • + *
    + * + * @throws InterruptedException if the thread is interrupted while waiting + */ + public synchronized void acquireNetwork() throws InterruptedException { + networksOpWaiting++; + while (electionOpInProgress || networkInProgress) { + wait(); + } + + networksOpWaiting--; + networkInProgress = true; + } + + /** + * Releases the network operation lock and notifies all waiting threads. + */ + public synchronized void releaseNetwork() { + networksOpSinceLastElection++; + networkInProgress = false; + notifyAll(); + } +} diff --git a/desm-network/src/main/java/org/example/desm/network/structure/SlidingWindowBuffer.java b/desm-network/src/main/java/org/example/desm/network/structure/SlidingWindowBuffer.java new file mode 100644 index 0000000..cdfdcea --- /dev/null +++ b/desm-network/src/main/java/org/example/desm/network/structure/SlidingWindowBuffer.java @@ -0,0 +1,73 @@ +package org.example.desm.network.structure; + +import org.example.desm.common.dto.Measurement; + +import java.util.ArrayList; +import java.util.List; + +/** + * Thi class implements a sliding window buffer for pollution measurements. + *

    + * It maintains a fixed-size buffer of measurements and computes the average + * when the buffer is full, sliding the window by half its size. + */ +public class SlidingWindowBuffer implements Buffer { + public static final int WINDOW_SIZE = 8; + public static final int SLIDE_SIZE = WINDOW_SIZE / 2; + + private final String currentPlantId; + private final List buffer; + private final List computedAverages; + + /** + * Constructs a {@link SlidingWindowBuffer} for the specified plant ID. + * + * @param currentPlantId the ID of the plant for which this buffer is created + */ + public SlidingWindowBuffer(String currentPlantId) { + this.currentPlantId = currentPlantId; + this.buffer = new ArrayList<>(); + this.computedAverages = new ArrayList<>(); + } + + /** + * Add a measurement to the buffer. If the buffer reaches its + * maximum size, it computes the average of the measurements + * and slides the window by half its size. + */ + @Override + public synchronized void addMeasurement(Measurement m) { + buffer.add(m); + if (buffer.size() == WINDOW_SIZE) { + Measurement computedAverage = computeAverage(); + computedAverages.add(computedAverage); + buffer.subList(0, SLIDE_SIZE).clear(); + } + } + + /** + * Reads all computed averages and clears the buffer. + * + * @return a list of computed average {@link Measurement} + */ + @Override + public synchronized List readAllAndClean() { + List result = List.copyOf(computedAverages); + computedAverages.clear(); + return result; + } + + /** + * Computes the average of the measurements in the buffer. + * + * @return a {@link Measurement} representing the computed average + */ + private Measurement computeAverage() { + double sum = 0; + for (Measurement measurement : buffer) { + sum += measurement.getValue(); + } + double avg = sum / buffer.size(); + return new Measurement(currentPlantId, "CO2_AVG", avg, System.currentTimeMillis()); + } +} \ No newline at end of file diff --git a/desm-network/src/main/proto/RingService.proto b/desm-network/src/main/proto/RingService.proto new file mode 100644 index 0000000..5261e7b --- /dev/null +++ b/desm-network/src/main/proto/RingService.proto @@ -0,0 +1,93 @@ +syntax = "proto3"; +package org.example.desm.grpc; + +import "google/protobuf/empty.proto"; + +service RingService { + /* Network */ + rpc Join(JoinRequest) returns (JoinReply); + rpc Welcome(WelcomeNotification) returns (google.protobuf.Empty); + rpc Joined(JoinedNotification) returns (google.protobuf.Empty); + + /* Election */ + rpc Election(ElectionMessage) returns (google.protobuf.Empty); + rpc Elected(ElectedMessage) returns (google.protobuf.Empty); + rpc Rejected(RejectedMessage) returns (google.protobuf.Empty); +} + +/* ----- Messages ----- */ + +/** + * Represents a peer in the ring. + */ +message Peer { + string id = 1; + string address = 2; + int32 port = 3; +} + +/** + * Used to join the ring. + */ +message JoinRequest { + Peer requester = 1; +} + +/** + * Response of a join request the new node that want to join the ring. + * It indicates whether the receiver is already part of the ring and then + * accepts or not. + */ +message JoinReply { + bool accepted = 1; +} + +/** + * Used to welcome a new peer to the ring. + */ +message WelcomeNotification { + Peer successor = 2; +} + +/** + * Used to announce that a new peer has joined the ring. + */ +message JoinedNotification { + Peer new_peer = 1; +} + +/** + * EnergyRequest represents a request for energy from the renewable energy provider. + */ +message EnergyRequest { + string id = 1; + int64 kwh_power = 2; + int64 timestamp = 3; + int32 attempt_number = 4; +} + +/** + * Used in the Chang-Roberts election algorithm. + */ +message ElectionMessage { + EnergyRequest energy_request = 1; + string candidate_id = 2; + double candidate_bid_price = 3; +} + +/** + * Used to announces the winner of an election. + */ +message ElectedMessage { + EnergyRequest energy_request = 1; + string winner_id = 2; + double winner_bid_price = 3; +} + +/** + * Used to reject an election by the winner. + */ +message RejectedMessage { + EnergyRequest energy_request = 1; + string winner_id = 2; +} \ No newline at end of file diff --git a/desm-network/src/test/java/org/example/desm/network/pollution/PeriodicMeasurementSenderTest.java b/desm-network/src/test/java/org/example/desm/network/pollution/PeriodicMeasurementSenderTest.java new file mode 100644 index 0000000..e37ce61 --- /dev/null +++ b/desm-network/src/test/java/org/example/desm/network/pollution/PeriodicMeasurementSenderTest.java @@ -0,0 +1,92 @@ +package org.example.desm.network.pollution; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.eclipse.paho.client.mqttv3.IMqttClient; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.example.desm.common.dto.Measurement; +import org.example.desm.network.structure.Buffer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Type; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PeriodicMeasurementSenderTest { + @Mock + IMqttClient client; + @Mock + Buffer buffer; + + @Captor + ArgumentCaptor messageCaptor; + + PeriodicMeasurementSender simulator; + final String plantId = "plant"; + final String topic = "test"; + final int qos = 2; + final List measurements = List.of( + new Measurement(plantId, "CO2", 10, System.currentTimeMillis()), + new Measurement(plantId, "CO2", 20, System.currentTimeMillis()), + new Measurement(plantId, "CO2", 30, System.currentTimeMillis()) + ); + + + @BeforeEach + void setUp() { + simulator = new PeriodicMeasurementSender(buffer, client, topic, qos, 10); + } + + @Test + void testPublishesMessage() throws MqttException, InterruptedException { + when(buffer.readAllAndClean()).thenReturn(measurements); + + simulator.start(); + // Wait to ensure the message is sent + Thread.sleep(200); + simulator.stopMeGently(); + simulator.join(); + + verify(client, atLeastOnce()).publish(eq(topic), messageCaptor.capture()); + + MqttMessage capturedMessage = messageCaptor.getValue(); + assertNotNull(capturedMessage); + + String payload = new String(capturedMessage.getPayload()); + Type listType = new TypeToken>() {}.getType(); + List captured = new Gson().fromJson(payload, listType); + + assertEquals(measurements, captured); + assertEquals(qos, capturedMessage.getQos()); + } + + @Test + void testNullMessage() throws MqttException, InterruptedException { + when(buffer.readAllAndClean()).thenReturn(null); + + simulator.start(); + // Wait to ensure the message is sent + Thread.sleep(200); + simulator.stopMeGently(); + simulator.join(); + + // Verify that publish was called at least once + verify(client, never()).publish(anyString(), any(MqttMessage.class)); + } +} \ No newline at end of file diff --git a/desm-network/src/test/java/org/example/desm/network/structure/BusyMonitorTest.java b/desm-network/src/test/java/org/example/desm/network/structure/BusyMonitorTest.java new file mode 100644 index 0000000..ea04f1d --- /dev/null +++ b/desm-network/src/test/java/org/example/desm/network/structure/BusyMonitorTest.java @@ -0,0 +1,66 @@ +package org.example.desm.network.structure; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.*; + +class BusyMonitorTest { + BusyMonitor monitor; + + @BeforeEach + void setUp() { + monitor = new BusyMonitor(); + } + + @Test + void testSetAndIsBusy() { + monitor.setBusy(true); + assertTrue(monitor.isBusy()); + + monitor.setBusy(false); + assertFalse(monitor.isBusy()); + } + + @Test + void testWaitUntilNotBusyReturnsImmediatelyIfNotBusy() throws InterruptedException { + long start = System.currentTimeMillis(); + monitor.waitUntilNotBusy(); + long duration = System.currentTimeMillis() - start; + + // Should return immediately + assertTrue(duration < 100); + } + + @Test + void testWaitUntilNotBusyBlocksUntilNotified() throws InterruptedException { + monitor.setBusy(true); + CountDownLatch latch = new CountDownLatch(1); + AtomicBoolean completed = new AtomicBoolean(false); + + Thread waiter = new Thread(() -> { + try { + latch.countDown(); + monitor.waitUntilNotBusy(); + completed.set(true); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + waiter.start(); + latch.await(); + + Thread.sleep(100); + assertFalse(completed.get()); + + // Set to not busy, should unblock thread + monitor.setBusy(false); + + waiter.join(); + assertTrue(completed.get()); + } +} \ No newline at end of file diff --git a/desm-network/src/test/java/org/example/desm/network/structure/ElectionMessageComparatorTest.java b/desm-network/src/test/java/org/example/desm/network/structure/ElectionMessageComparatorTest.java new file mode 100644 index 0000000..fe77beb --- /dev/null +++ b/desm-network/src/test/java/org/example/desm/network/structure/ElectionMessageComparatorTest.java @@ -0,0 +1,38 @@ +package org.example.desm.network.structure; + +import org.example.desm.grpc.RingServiceOuterClass; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ElectionMessageComparatorTest { + final ElectionMessageComparator comparator = new ElectionMessageComparator(); + + @Test + void testBaseCompareElectionMessages() { + RingServiceOuterClass.ElectionMessage m1 = RingServiceOuterClass.ElectionMessage.newBuilder() + .setCandidateId("a") + .setCandidateBidPrice(1.0) + .build(); + RingServiceOuterClass.ElectionMessage m2 = RingServiceOuterClass.ElectionMessage.newBuilder() + .setCandidateId("b") + .setCandidateBidPrice(2.0) + .build(); + assertEquals(-1, comparator.compare(m1, m2)); + assertEquals(1, comparator.compare(m2, m1)); + } + + @Test + void testEqualBidCompareElectionMessages() { + RingServiceOuterClass.ElectionMessage m1 = RingServiceOuterClass.ElectionMessage.newBuilder() + .setCandidateId("a") + .setCandidateBidPrice(1.0) + .build(); + RingServiceOuterClass.ElectionMessage m2 = RingServiceOuterClass.ElectionMessage.newBuilder() + .setCandidateId("b") + .setCandidateBidPrice(1.0) + .build(); + assertEquals(1, comparator.compare(m1, m2)); + assertEquals(-1, comparator.compare(m2, m1)); + } +} \ No newline at end of file diff --git a/desm-network/src/test/java/org/example/desm/network/structure/EnergyRequestQueueTest.java b/desm-network/src/test/java/org/example/desm/network/structure/EnergyRequestQueueTest.java new file mode 100644 index 0000000..b01d2d7 --- /dev/null +++ b/desm-network/src/test/java/org/example/desm/network/structure/EnergyRequestQueueTest.java @@ -0,0 +1,141 @@ +package org.example.desm.network.structure; + +import org.example.desm.grpc.RingServiceOuterClass; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +class EnergyRequestQueueTest { + EnergyRequestQueue queue; + + @BeforeEach + void setUp() { + queue = new EnergyRequestQueue(); + } + + @Test + void testEnqueueDequeue() throws InterruptedException { + RingServiceOuterClass.EnergyRequest request = createRequest("A", 100, 100, 0); + + queue.enqueue(request); + RingServiceOuterClass.EnergyRequest result = queue.dequeue(); + + assertEquals(request, result); + assertEquals(0, queue.size()); + } + + @Test + void testPriorityOrder() throws InterruptedException { + List requests = List.of( + createRequest("C", 300, 300, 0), + createRequest("A", 100, 100, 0), + createRequest("B", 200, 200, 0) + ); + + for (RingServiceOuterClass.EnergyRequest request : requests) { + queue.enqueue(request); + } + + List sorted = requests.stream() + .sorted(Comparator.comparingLong(RingServiceOuterClass.EnergyRequest::getTimestamp)) + .toList(); + for (RingServiceOuterClass.EnergyRequest request : sorted) { + assertEquals(request, queue.dequeue()); + } + } + + @Test + void testDequeueBlocksUntilElementIsAvailable() throws InterruptedException { + AtomicReference dequeued = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + + Thread consumer = new Thread(() -> { + try { + latch.countDown(); + RingServiceOuterClass.EnergyRequest req = queue.dequeue(); + dequeued.set(req); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + consumer.start(); + latch.await(); + Thread.sleep(200); + + assertNull(dequeued.get()); + + RingServiceOuterClass.EnergyRequest request = createRequest("C", 300, 300, 0); + queue.enqueue(request); + + consumer.join(); + assertEquals(request, dequeued.get()); + } + + @Test + void testConcurrentAccess() throws InterruptedException { + List producerRequests = List.of( + createRequest("A", 100, 100, 0), + createRequest("B", 200, 200, 1), + createRequest("C", 300, 300, 2), + createRequest("D", 400, 400, 3), + createRequest("E", 500, 500, 4) + ); + + List consumerRequests = new ArrayList<>(); + + Thread producer = new Thread(() -> { + for (RingServiceOuterClass.EnergyRequest request : producerRequests) { + queue.enqueue(request); + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + queue.close(); + }); + + Thread consumer = new Thread(() -> { + try { + RingServiceOuterClass.EnergyRequest req; + while ((req = queue.dequeue()) != null) { + consumerRequests.add(req); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + producer.start(); + consumer.start(); + producer.join(); + consumer.join(); + + assertEquals(producerRequests.size(), consumerRequests.size()); + for (RingServiceOuterClass.EnergyRequest request : consumerRequests) { + assertTrue(producerRequests.contains(request)); + } + } + + private RingServiceOuterClass.EnergyRequest createRequest( + String id, + int kwhPower, + long timestamp, + int attemptNumber + ) { + return RingServiceOuterClass.EnergyRequest.newBuilder() + .setId(id) + .setKwhPower(kwhPower) + .setTimestamp(timestamp) + .setAttemptNumber(attemptNumber) + .build(); + } +} \ No newline at end of file diff --git a/desm-network/src/test/java/org/example/desm/network/structure/NetworkRepositoryTest.java b/desm-network/src/test/java/org/example/desm/network/structure/NetworkRepositoryTest.java new file mode 100644 index 0000000..4e1a01b --- /dev/null +++ b/desm-network/src/test/java/org/example/desm/network/structure/NetworkRepositoryTest.java @@ -0,0 +1,62 @@ +package org.example.desm.network.structure; + +import org.example.desm.common.dto.PowerPlant; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +class NetworkRepositoryTest { + static List plants = Arrays.asList( + new PowerPlant("1", "localhost", 9000), + new PowerPlant("2", "localhost", 9001), + new PowerPlant("3", "localhost", 9002), + new PowerPlant("4", "localhost", 9003), + new PowerPlant("5", "localhost", 9004) + ); + NetworkRepository ring; + NetworkRepository singleNodeRing; + + @BeforeEach + void setUp() { + ring = new NetworkRepository(plants.get(0)); + ring.addAll(plants); + singleNodeRing = new NetworkRepository(plants.get(0)); + singleNodeRing.add(plants.get(0)); + } + + @Test + void testThereIsSomeoneElse() { + assertTrue(ring.thereIsSomeoneElse()); + assertFalse(singleNodeRing.thereIsSomeoneElse()); + } + + @ParameterizedTest + @MethodSource("predecessorTestCases") + void testGetPredecessorsChain(NetworkRepository ring, List expected) { + assertEquals(expected, ring.getPredecessorsChain()); + } + + static Stream predecessorTestCases() { + return Stream.of( + Arguments.of(createRing(plants, 0), List.of(plants.get(4), plants.get(3), plants.get(2), plants.get(1))), + Arguments.of(createRing(plants, 1), List.of(plants.get(0), plants.get(4), plants.get(3), plants.get(2))), + Arguments.of(createRing(plants, 2), List.of(plants.get(1), plants.get(0), plants.get(4), plants.get(3))), + Arguments.of(createRing(plants, 3), List.of(plants.get(2), plants.get(1), plants.get(0), plants.get(4))), + Arguments.of(createRing(plants, 4), List.of(plants.get(3), plants.get(2), plants.get(1), plants.get(0))) + ); + } + + static NetworkRepository createRing(List plants, int currentIndex) { + NetworkRepository ring = new NetworkRepository(plants.get(currentIndex)); + ring.addAll(plants); + return ring; + } +} \ No newline at end of file diff --git a/desm-network/src/test/java/org/example/desm/network/structure/PeerLockTest.java b/desm-network/src/test/java/org/example/desm/network/structure/PeerLockTest.java new file mode 100644 index 0000000..4505f8b --- /dev/null +++ b/desm-network/src/test/java/org/example/desm/network/structure/PeerLockTest.java @@ -0,0 +1,117 @@ +package org.example.desm.network.structure; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.*; + +import static org.junit.jupiter.api.Assertions.*; + +class PeerLockTest { + PeerLock lock; + + @BeforeEach + void setUp() { + lock = new PeerLock(); + } + + @Test + void testNetworkAndElectionMutualExclusion() throws InterruptedException { + CountDownLatch joinStarted = new CountDownLatch(1); + CountDownLatch electionCompleted = new CountDownLatch(1); + + new Thread(() -> { + try { + lock.acquireNetwork(); + joinStarted.countDown(); + lock.releaseNetwork(); + } catch (InterruptedException ignored) {} + }).start(); + + new Thread(() -> { + try { + joinStarted.await(); + lock.acquireElection(); + electionCompleted.countDown(); + lock.releaseElection(); + } catch (InterruptedException ignored) {} + }).start(); + + assertTrue(joinStarted.await(10000, TimeUnit.MILLISECONDS)); + assertTrue(electionCompleted.await(10000, TimeUnit.MILLISECONDS)); + } + + @Test + void testElectionStarvationAvoided() throws InterruptedException { + int joiner = PeerLock.MAX_CONSECUTIVE_NETWORKS * 2; + CountDownLatch joinCompleted = new CountDownLatch(joiner); + CountDownLatch electionCompleted = new CountDownLatch(1); + + // Launch repeated joiners + for (int i = 0; i < joiner; i++) { + new Thread(() -> { + try { + lock.acquireNetwork(); + // simulate join work + Thread.sleep(50); + joinCompleted.countDown(); + lock.releaseNetwork(); + } catch (InterruptedException ignored) {} + }).start(); + } + + // Delay election to ensure joiners queue up + Thread.sleep(100); + + new Thread(() -> { + try { + lock.acquireElection(); + assertNotEquals(0, joiner - joinCompleted.getCount()); + assertNotEquals(joiner, joiner - joinCompleted.getCount()); + electionCompleted.countDown(); + lock.releaseElection(); + } catch (InterruptedException ignored) {} + }).start(); + + assertTrue(joinCompleted.await(10000, TimeUnit.MILLISECONDS)); + assertTrue(electionCompleted.await(10000, TimeUnit.MILLISECONDS)); + } + + @Test + void testMultipleElectionWithoutNetwork() throws InterruptedException { + int elections = 10; + CountDownLatch electionsCompleted = new CountDownLatch(elections); + + for (int i = 0; i < elections; i++) { + new Thread(() -> { + try { + lock.acquireElection(); + Thread.sleep(50); + electionsCompleted.countDown(); + lock.releaseElection(); + } catch (InterruptedException ignored) {} + }).start(); + } + + assertTrue(electionsCompleted.await(10000, TimeUnit.MILLISECONDS)); + } + + @Test + void testMultipleNetworkGreaterThanMaxConsecutiveWithoutElection() throws InterruptedException { + int joiner = PeerLock.MAX_CONSECUTIVE_NETWORKS * 2; + CountDownLatch joinCompleted = new CountDownLatch(joiner); + + for (int i = 0; i < joiner; i++) { + new Thread(() -> { + try { + lock.acquireNetwork(); + Thread.sleep(50); + joinCompleted.countDown(); + lock.releaseNetwork(); + } catch (InterruptedException ignored) {} + }).start(); + } + + assertTrue(joinCompleted.await(10000, TimeUnit.MILLISECONDS)); + } +} diff --git a/desm-network/src/test/java/org/example/desm/network/structure/SlidingWindowBufferTest.java b/desm-network/src/test/java/org/example/desm/network/structure/SlidingWindowBufferTest.java new file mode 100644 index 0000000..bd5cf85 --- /dev/null +++ b/desm-network/src/test/java/org/example/desm/network/structure/SlidingWindowBufferTest.java @@ -0,0 +1,111 @@ +package org.example.desm.network.structure; + +import org.example.desm.common.dto.Measurement; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class SlidingWindowBufferTest { + SlidingWindowBuffer buffer; + final String plantId = "Plant"; + + @BeforeEach + void setUp() { + buffer = new SlidingWindowBuffer(plantId); + } + + @Test + void testAddMeasurementAndComputeAverageMeasurements() { + double expectedAvg = 0.0; + int measurementCount = 8; + + for (int i = 0; i < measurementCount; i++) { + double value = i * Math.random() * 100.0; + expectedAvg += value; + Measurement m = new Measurement(plantId, "CO2", value, System.currentTimeMillis()); + buffer.addMeasurement(m); + } + + expectedAvg = expectedAvg / measurementCount; + + List results = buffer.readAllAndClean(); + assertEquals(1, results.size()); + + Measurement avg = results.get(0); + assertEquals(plantId, avg.getId()); + + assertEquals(expectedAvg, avg.getValue(), 0.001); + } + + @Test + void testSlidingWindowBehavior() { + int measurementCount = 16; + double[] measurements = new double[measurementCount]; + + for (int i = 0; i < measurementCount; i++) { + double value = i * Math.random() * 100.0; + measurements[i] = value; + Measurement m = new Measurement("Plant", "CO2", value, System.currentTimeMillis()); + buffer.addMeasurement(m); + } + + int expectedWindows = calculateNumberOfWindows(measurementCount); + List results = buffer.readAllAndClean(); + assertEquals(expectedWindows, results.size()); + + double[] expectedAvg = new double[expectedWindows]; + for (int i = 0; i < expectedWindows; i++) { + int startIndex = i * SlidingWindowBuffer.SLIDE_SIZE; + int endIndex = startIndex + SlidingWindowBuffer.WINDOW_SIZE; + expectedAvg[i] = calculateAverage(measurements, startIndex, endIndex); + } + + for (int i = 0; i < expectedWindows; i++) { + assertEquals(plantId, results.get(i).getId()); + assertEquals(expectedAvg[i], results.get(i).getValue(), 0.001); + } + } + + @Test + void testReadAllAndCleanEmptiesTheAveragesMeasurements() { + for (int i = 0; i < SlidingWindowBuffer.WINDOW_SIZE; i++) { + buffer.addMeasurement(new Measurement(plantId, "CO2", i, System.currentTimeMillis())); + } + + List firstRead = buffer.readAllAndClean(); + assertEquals(1, firstRead.size()); + + List secondRead = buffer.readAllAndClean(); + assertEquals(0, secondRead.size()); + } + + @Test + void testBufferDoesNotComputeAverageMeasurementsIfNotFull() { + for (int i = 0; i < SlidingWindowBuffer.WINDOW_SIZE - 1; i++) { + buffer.addMeasurement(new Measurement(plantId, "CO2", i, System.currentTimeMillis())); + } + + List averages = buffer.readAllAndClean(); + assertEquals(0, averages.size()); + } + + public static int calculateNumberOfWindows(int totalMeasurements) { + if (totalMeasurements < SlidingWindowBuffer.WINDOW_SIZE) return 0; + return (totalMeasurements - SlidingWindowBuffer.WINDOW_SIZE) / SlidingWindowBuffer.SLIDE_SIZE + 1; + } + + private double calculateAverage(double[] measurements, int startIndex, int endIndex) { + double sum = 0.0; + int count = 0; + + for (int i = startIndex; i < endIndex; i++) { + sum += measurements[i]; + count++; + } + + return sum / count; + } +} \ No newline at end of file diff --git a/desm-provider/build.gradle b/desm-provider/build.gradle new file mode 100644 index 0000000..c61273a --- /dev/null +++ b/desm-provider/build.gradle @@ -0,0 +1,7 @@ +application { + mainClassName = "org.example.desm.provider.ProviderApplication" +} + +run { + standardInput = System.in +} diff --git a/desm-provider/src/main/java/org/example/desm/provider/PeriodicEnergyRequestSender.java b/desm-provider/src/main/java/org/example/desm/provider/PeriodicEnergyRequestSender.java new file mode 100644 index 0000000..0948e85 --- /dev/null +++ b/desm-provider/src/main/java/org/example/desm/provider/PeriodicEnergyRequestSender.java @@ -0,0 +1,58 @@ +package org.example.desm.provider; + +import com.google.gson.Gson; +import org.eclipse.paho.client.mqttv3.IMqttClient; +import org.example.desm.common.PeriodicMqttMessageSender; +import org.example.desm.common.dto.EnergyRequest; + +import java.util.Random; +import java.util.UUID; + +/** + * This class extend {@link PeriodicMqttMessageSender} and simulates a periodic power + * request simulator that sends power requests to a specified MQTT topic at regular intervals. + */ +public class PeriodicEnergyRequestSender extends PeriodicMqttMessageSender { + private static final long INTERVAL_MS = 10000; + private static final int PUB_QOS = 2; + private static final int MIN_POWER = 5000; + private static final int MAX_POWER = 15000; + private static final Random random = new Random(); + private static final Gson gson = new Gson(); + + /** + * Constructs {@link PeriodicEnergyRequestSender} with custom parameters. + * + * @param client the MQTT client used to publish messages. + * @param topic the topic to publish energy requests to. + * @param qos the MQTT Quality of Service level. + * @param intervalMs the interval between requests in milliseconds. + */ + public PeriodicEnergyRequestSender(IMqttClient client, String topic, int qos, long intervalMs) { + super(client, topic, qos, intervalMs); + } + + /** + * Constructs {@link PeriodicEnergyRequestSender} with default parameters (QoS 2, interval 10 seconds). + * + * @param client the MQTT client used to publish messages. + * @param topic the topic to publish energy requests to. + */ + public PeriodicEnergyRequestSender(IMqttClient client, String topic) { + this(client, topic, PUB_QOS, INTERVAL_MS); + } + + /** + * Get the power request message as a JSON string. + * + * @return the JSON string representation of the {@link EnergyRequest}. + */ + @Override + protected String getMqttMessage() { + String id = UUID.randomUUID().toString(); + int power = random.nextInt((MAX_POWER - MIN_POWER) + 1) + MIN_POWER; + long timestamp = System.currentTimeMillis(); + EnergyRequest energyRequest = new EnergyRequest(id, power, timestamp); + return gson.toJson(energyRequest); + } +} diff --git a/desm-provider/src/main/java/org/example/desm/provider/ProviderApplication.java b/desm-provider/src/main/java/org/example/desm/provider/ProviderApplication.java new file mode 100644 index 0000000..88073f3 --- /dev/null +++ b/desm-provider/src/main/java/org/example/desm/provider/ProviderApplication.java @@ -0,0 +1,62 @@ +package org.example.desm.provider; + +import org.eclipse.paho.client.mqttv3.MqttException; + +import java.util.Scanner; + +public class ProviderApplication { + public static void main(String[] args) { + Scanner scanner = new Scanner(System.in); + RenewableEnergyProvider provider = null; + + try { + provider = new RenewableEnergyProvider(); + } catch (MqttException e) { + System.err.printf("[MQTT] Initialization error in RenewableEnergyProvider: %s%n", e.getMessage()); + } + + try { + if (provider != null) { + provider.start(); + } + } catch (MqttException e) { + provider.shutdown(); + provider = null; + System.err.printf("[MQTT] Error on MQTT client: %s%n", e.getMessage()); + } + + if (provider == null) { + System.err.println("Failed to initialize RenewableEnergyProvider. Exiting..."); + return; + } + + // CLI commands + System.out.println(); + System.out.println("*************************************"); + System.out.println("***** Type 'exit' to disconnect *****"); + System.out.println("*************************************"); + System.out.println(); + + runCommandLoop(scanner, provider); + scanner.close(); + } + + /** + * Runs the command loop for the Renewable Energy Provider. + * + * @param scanner the scanner to read user input. + * @param provider the Renewable Energy Provider to run the command loop for. + */ + private static void runCommandLoop(Scanner scanner, RenewableEnergyProvider provider) { + boolean isRunning = true; + while (isRunning) { + String command = scanner.nextLine().trim().toLowerCase(); + if (command.equals("exit")) { + isRunning = false; + provider.shutdown(); + } else { + System.out.println("Unknown command."); + } + } + } +} diff --git a/desm-provider/src/main/java/org/example/desm/provider/RenewableEnergyProvider.java b/desm-provider/src/main/java/org/example/desm/provider/RenewableEnergyProvider.java new file mode 100644 index 0000000..19e80ba --- /dev/null +++ b/desm-provider/src/main/java/org/example/desm/provider/RenewableEnergyProvider.java @@ -0,0 +1,155 @@ +package org.example.desm.provider; + +import org.eclipse.paho.client.mqttv3.IMqttClient; +import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken; +import org.eclipse.paho.client.mqttv3.MqttCallback; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttConnectOptions; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence; + +import java.util.Arrays; + +import static org.example.desm.common.Constant.TOPIC_ENERGY_REQUEST; +import static org.example.desm.common.Constant.MQTT_BROKER_ADDRESS; + +/** + * Renewable energy provider in the DESM system. + * Periodically publishes energy requests to an MQTT topic. + * + *

    + * This class establishes an MQTT connection and starts a simulator thread + * to broadcast power requests every 10 seconds. + */ +public class RenewableEnergyProvider implements MqttCallback { + private final IMqttClient client; + private final PeriodicEnergyRequestSender periodicSendPowerRequest; + + /** + * Constructs a {@link RenewableEnergyProvider} with a given MQTT client. + * + * @param client the MQTT client instance. + */ + public RenewableEnergyProvider( + IMqttClient client, + PeriodicEnergyRequestSender periodicSendPowerRequest + ) { + this.client = client; + this.periodicSendPowerRequest = periodicSendPowerRequest; + } + + /** + * Default constructor of {@link RenewableEnergyProvider}. + * + * @throws MqttException if there is an error creating the MQTT client. + */ + public RenewableEnergyProvider() throws MqttException { + this.client = new MqttClient( + MQTT_BROKER_ADDRESS, + MqttClient.generateClientId(), + new MemoryPersistence() + ); + this.periodicSendPowerRequest = new PeriodicEnergyRequestSender( + client, + TOPIC_ENERGY_REQUEST + ); + } + + /** + * Starts the provider: connects to MQTT broker and begins publishing power requests. + * Waits for user input to terminate and cleanly shuts down the simulator and connection. + * + * @throws MqttException if there is an error connecting to the broker. + */ + public void start() throws MqttException { + connectToBroker(); + startRequesting(); + } + + /** + * Disconnects the MQTT client and waits for the simulator thread to finish. + */ + public void shutdown() { + try { + System.out.println("\nShutting down..."); + disconnectFromMqttBroker(); + stopRequesting(); + System.out.println("Shutdown complete!\n"); + } catch (MqttException e) { + System.err.printf("[MQTT] Error during MQTT client disconnection: %s%n", e.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + System.out.println("Shutdown interrupted."); + } + } + + /** + * Connects to the MQTT broker using the configured client. + * + * @throws MqttException if there is an error connecting to the broker. + */ + private void connectToBroker() throws MqttException { + MqttConnectOptions options = new MqttConnectOptions(); + options.setCleanSession(true); + options.setAutomaticReconnect(true); + + System.out.printf("[MQTT] Connecting to broker %s...%n", MQTT_BROKER_ADDRESS); + client.setCallback(this); + client.connect(options); + System.out.println("[MQTT] Connected!"); + } + + /** + * Starts the periodic power request sender. + */ + private void startRequesting() { + periodicSendPowerRequest.start(); + } + + /** + * Disconnects from the MQTT broker if connected. + * + * @throws MqttException if there is an error during disconnection. + */ + private void disconnectFromMqttBroker() throws MqttException { + if (client.isConnected()) { + System.out.printf("[MQTT] Disconnecting from broker %s...%n", MQTT_BROKER_ADDRESS); + client.disconnect(); + System.out.println("[MQTT] Disconnected from broker!"); + } + } + + /** + * Stops the periodic power request sender and waits for it to finish. + * + * @throws InterruptedException if the thread is interrupted while waiting. + */ + private void stopRequesting() throws InterruptedException { + if (periodicSendPowerRequest.isAlive()) { + System.out.println("Stopping the periodic power request sender..."); + periodicSendPowerRequest.stopMeGently(); + + System.out.println("Waiting for the periodic power request sender to finish..."); + periodicSendPowerRequest.join(); + System.out.println("Periodic power request sender has finished."); + } + } + + @Override + public void connectionLost(Throwable cause) { + System.err.println("[MQTT] Connection lost with MQTT broker: " + cause.getMessage()); + // Automatic reconnect is enabled, so no need to handle reconnection + } + + @Override + public void messageArrived(String topic, MqttMessage message) { + // Not used in publisher + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + System.out.printf("[MQTT] Delivery complete on topics %s for message with ID %d.%n", + Arrays.toString(token.getTopics()), token.getMessageId()); + } +} diff --git a/desm-provider/src/test/java/org/example/desm/provider/PeriodicEnergyRequestSenderTest.java b/desm-provider/src/test/java/org/example/desm/provider/PeriodicEnergyRequestSenderTest.java new file mode 100644 index 0000000..24f71b1 --- /dev/null +++ b/desm-provider/src/test/java/org/example/desm/provider/PeriodicEnergyRequestSenderTest.java @@ -0,0 +1,58 @@ +package org.example.desm.provider; + +import com.google.gson.Gson; +import org.eclipse.paho.client.mqttv3.IMqttClient; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.example.desm.common.dto.EnergyRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class PeriodicEnergyRequestSenderTest { + @Mock + IMqttClient client; + + @Captor + ArgumentCaptor messageCaptor; + + PeriodicEnergyRequestSender simulator; + final String topic = "test"; + final int qos = 2; + + @BeforeEach + void setUp() { + simulator = new PeriodicEnergyRequestSender(client, topic, qos, 10); + } + + @Test + void testPublishesMessage() throws MqttException, InterruptedException { + simulator.start(); + // Wait to ensure the message is sent + Thread.sleep(200); + simulator.stopMeGently(); + simulator.join(); + + verify(client, atLeastOnce()).publish(eq(topic), messageCaptor.capture()); + + MqttMessage capturedMessage = messageCaptor.getValue(); + assertNotNull(capturedMessage); + + String payload = new String(capturedMessage.getPayload()); + EnergyRequest captured = new Gson().fromJson(payload, EnergyRequest.class); + + assertTrue(captured.getKwhPower() >= 5000 && captured.getKwhPower() <= 15000); + + assertEquals(qos, capturedMessage.getQos()); + } +} \ No newline at end of file diff --git a/desm-provider/src/test/java/org/example/desm/provider/RenewableEnergyProviderTest.java b/desm-provider/src/test/java/org/example/desm/provider/RenewableEnergyProviderTest.java new file mode 100644 index 0000000..d8ac700 --- /dev/null +++ b/desm-provider/src/test/java/org/example/desm/provider/RenewableEnergyProviderTest.java @@ -0,0 +1,45 @@ +package org.example.desm.provider; + +import org.eclipse.paho.client.mqttv3.IMqttClient; +import org.eclipse.paho.client.mqttv3.MqttConnectOptions; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RenewableEnergyProviderTest { + @Mock + IMqttClient mqttClient; + + @Mock + PeriodicEnergyRequestSender periodicSendPowerRequest; + + RenewableEnergyProvider provider; + + @BeforeEach + void setUp() { + provider = new RenewableEnergyProvider(mqttClient, periodicSendPowerRequest); + } + + @Test + public void testStartSuccessfulConnection() throws MqttException { + when(mqttClient.isConnected()).thenReturn(true); + + provider.start(); + provider.shutdown(); + + verify(mqttClient).setCallback(provider); + verify(mqttClient).connect(any(MqttConnectOptions.class)); + verify(periodicSendPowerRequest).start(); + verify(mqttClient).disconnect(); + assertFalse(periodicSendPowerRequest.isAlive()); + } +} \ No newline at end of file diff --git a/desm-server/build.gradle b/desm-server/build.gradle new file mode 100644 index 0000000..f5cd334 --- /dev/null +++ b/desm-server/build.gradle @@ -0,0 +1,19 @@ +plugins { + id 'org.springframework.boot' version '3.4.5' + id 'io.spring.dependency-management' version '1.1.7' +} + +application { + mainClassName = "org.example.desm.server.DesmServer" +} + +run { + standardInput = System.in +} + +dependencies { + // Spring Boot dependencies + implementation 'org.springframework.boot:spring-boot-starter-web' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} diff --git a/desm-server/src/main/java/org/example/desm/server/DesmServer.java b/desm-server/src/main/java/org/example/desm/server/DesmServer.java new file mode 100644 index 0000000..e57fef2 --- /dev/null +++ b/desm-server/src/main/java/org/example/desm/server/DesmServer.java @@ -0,0 +1,13 @@ +package org.example.desm.server; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.web.bind.annotation.RestController; + +@SpringBootApplication +@RestController +public class DesmServer { + public static void main(String[] args) { + SpringApplication.run(DesmServer.class, args); + } +} diff --git a/desm-server/src/main/java/org/example/desm/server/GlobalControllerAdvice.java b/desm-server/src/main/java/org/example/desm/server/GlobalControllerAdvice.java new file mode 100644 index 0000000..7d928bb --- /dev/null +++ b/desm-server/src/main/java/org/example/desm/server/GlobalControllerAdvice.java @@ -0,0 +1,25 @@ +package org.example.desm.server; + +import org.example.desm.common.dto.ErrorResponse; +import org.example.desm.server.exception.ApiExceptions; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * Global exception handler for the application. + * This class handles exceptions thrown by controllers and returns appropriate error responses. + */ +@RestControllerAdvice +public class GlobalControllerAdvice { + /** + * Handles {@link ApiExceptions} thrown by controllers. + * + * @param ex the {@link ApiExceptions} instance. + * @return a {@link ResponseEntity} containing the error response. + */ + @ExceptionHandler(ApiExceptions.class) + public ResponseEntity plantAlreadyExistHandler(ApiExceptions ex) { + return ResponseEntity.status(ex.getStatusCode()).body(ex.buildResponse()); + } +} diff --git a/desm-server/src/main/java/org/example/desm/server/MqttClientFactory.java b/desm-server/src/main/java/org/example/desm/server/MqttClientFactory.java new file mode 100644 index 0000000..a82eea6 --- /dev/null +++ b/desm-server/src/main/java/org/example/desm/server/MqttClientFactory.java @@ -0,0 +1,32 @@ +package org.example.desm.server; + +import org.eclipse.paho.client.mqttv3.IMqttClient; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.example.desm.common.Constant.MQTT_BROKER_ADDRESS; + +/** + * Configuration class for creating an MQTT client bean. + */ +@Configuration +public class MqttClientFactory { + /** + * Creates an MQTT client bean that connects to the specified MQTT broker address. + * This method will be used for dependency injection in the Spring Boot application context. + * + * @return an instance of {@link IMqttClient} connected to the MQTT broker. + * @throws MqttException if there is an error creating the MQTT client. + */ + @Bean + public IMqttClient mqttClient() throws MqttException { + return new MqttClient( + MQTT_BROKER_ADDRESS, + MqttClient.generateClientId(), + new MemoryPersistence() + ); + } +} \ No newline at end of file diff --git a/desm-server/src/main/java/org/example/desm/server/PollutionSubscriber.java b/desm-server/src/main/java/org/example/desm/server/PollutionSubscriber.java new file mode 100644 index 0000000..059dd0f --- /dev/null +++ b/desm-server/src/main/java/org/example/desm/server/PollutionSubscriber.java @@ -0,0 +1,130 @@ +package org.example.desm.server; + +import com.google.gson.reflect.TypeToken; +import org.example.desm.common.RetryExecutor; +import org.example.desm.common.dto.Measurement; +import org.example.desm.server.service.PollutionService; +import com.google.gson.Gson; +import jakarta.annotation.PostConstruct; +import org.eclipse.paho.client.mqttv3.IMqttClient; +import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken; +import org.eclipse.paho.client.mqttv3.MqttCallback; +import org.eclipse.paho.client.mqttv3.MqttConnectOptions; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Type; +import java.util.List; + +import static org.example.desm.common.Constant.TOPIC_CO2_MEASUREMENT; +import static org.example.desm.common.Constant.MQTT_BROKER_ADDRESS; + +/** + * Subscriber component that listens for pollution measurements from the MQTT broker. + * It processes incoming messages and registers the measurements using the {@link PollutionService}. + *

    + * This component implements the {@link MqttCallback} interface to handle MQTT callback listener. + */ +@Component +public class PollutionSubscriber implements MqttCallback { + private static final long RETRY_INTERVAL_MS = 10000; + private static final int MAX_RETRIES = Integer.MAX_VALUE; + private static final int SUB_QOS = 2; + private static final Gson gson = new Gson(); + + private final IMqttClient client; + private final PollutionService pollutionService; + + /** + * Constructor for the {@link PollutionSubscriber} component. + * + * @param client the MQTT client to connect to the broker. + * @param pollutionService the service to register pollution measurements. + */ + @Autowired + public PollutionSubscriber(IMqttClient client, PollutionService pollutionService) { + this.client = client; + this.pollutionService = pollutionService; + } + + /** + * Initializes the subscriber by connecting to the MQTT broker and subscribing to the Co2 measurement topic. + * This method is called after the bean has been created and dependencies have been injected. + */ + @PostConstruct + public void init() { + new Thread(this::initConnectionAndSubscription).start(); + } + + public void initConnectionAndSubscription() { + try { + RetryExecutor.executeWithRetries( + this::connect, + MAX_RETRIES, + RETRY_INTERVAL_MS, + "MQTT Connection" + ); + + RetryExecutor.executeWithRetries( + this::subscribe, + MAX_RETRIES, + RETRY_INTERVAL_MS, + "Topic Subscription" + ); + } catch (IllegalStateException e) { + System.err.println("[MQTT] Critical error during MQTT initialization: " + e.getMessage()); + } + } + + /** + * Connects to the MQTT broker with the specified options. + * + * @throws MqttException if an error occurs while connecting to the broker. + */ + private void connect() throws MqttException { + MqttConnectOptions options = new MqttConnectOptions(); + options.setCleanSession(true); + options.setAutomaticReconnect(true); + + System.out.printf("[MQTT] Connecting to broker %s...%n", MQTT_BROKER_ADDRESS); + client.setCallback(this); + client.connect(options); + System.out.println("[MQTT] Connected!"); + } + + /** + * Subscribes to the Co2 measurement topic. + * + * @throws MqttException if an error occurs while subscribing to the topic. + */ + private void subscribe() throws MqttException { + System.out.printf("[MQTT] Subscribing to topic %s...%n", TOPIC_CO2_MEASUREMENT); + client.subscribe(TOPIC_CO2_MEASUREMENT, SUB_QOS); + System.out.println("[MQTT] Subscribed successfully."); + } + + @Override + public void connectionLost(Throwable cause) { + System.err.println("[MQTT] Connection lost with MQTT broker: " + cause.getMessage()); + // Automatic reconnect is enabled, so no need to handle reconnection + } + + @Override + public void messageArrived(String topic, MqttMessage message) { + System.out.printf("[MQTT] Received message on topic %s: %s%n", topic, message); + if (topic.equals(TOPIC_CO2_MEASUREMENT)) { + String payload = new String(message.getPayload()); + Type listType = new TypeToken>() { + }.getType(); + List measurements = gson.fromJson(payload, listType); + pollutionService.registerMeasurements(measurements); + } + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + // Not used in subscriber + } +} \ No newline at end of file diff --git a/desm-server/src/main/java/org/example/desm/server/ReadWriteLock.java b/desm-server/src/main/java/org/example/desm/server/ReadWriteLock.java new file mode 100644 index 0000000..b449ab4 --- /dev/null +++ b/desm-server/src/main/java/org/example/desm/server/ReadWriteLock.java @@ -0,0 +1,92 @@ +package org.example.desm.server; + +import java.util.LinkedList; +import java.util.Queue; + +/** + * Implementation of a Read-Write Lock using a FIFO queue. + *

    + * This lock ensures that: + *

      + *
    • Multiple readers can hold the lock simultaneously if no writer is active or waiting before them.
    • + *
    • Writers have exclusive access, blocking both readers and other writers.
    • + *
    • Requests are processed in FIFO order, avoiding readers or writers starvation exploiting fairness.
    • + *
    + *

    + * Each thread adds a request to a queue and waits until it reaches the head and the lock is available. + */ +public class ReadWriteLock { + /** + * A support class that represents a request to acquire a lock, either read or write. + */ + private static final class Request { + // Support class + } + + private int activeReaders = 0; + private boolean isWriting = false; + private final Queue queue = new LinkedList<>(); + + /** + * Acquires the read lock. Multiple threads can acquire the read lock as long as: + *

      + *
    • There is no writer currently holding the lock.
    • + *
    • The current read request is at the head of the queue.
    • + *
    + * + * @throws InterruptedException if the thread is interrupted while waiting. + */ + public synchronized void acquireRead() throws InterruptedException { + Request request = new Request(); + queue.add(request); + + while (queue.peek() != request || isWriting) { + wait(); + } + + queue.poll(); + activeReaders++; + } + + /** + * Releases the read lock. If no more readers are present, waiting writers + * may be notified. + */ + public synchronized void releaseRead() { + activeReaders--; + if (activeReaders == 0) { + notifyAll(); + } + } + + /** + * Acquires the write lock. This method blocks until: + *
      + *
    • The current write request is at the head of the queue.
    • + *
    • There are no active readers.
    • + *
    • There is no other writer holding the lock.
    • + *
    + * + * @throws InterruptedException if the thread is interrupted while waiting. + */ + public synchronized void acquireWrite() throws InterruptedException { + Request request = new Request(); + queue.add(request); + + while (queue.peek() != request || isWriting || activeReaders > 0) { + wait(); + } + + queue.poll(); + isWriting = true; + } + + /** + * Releases the write lock. Notifies waiting readers or writers so they + * can attempt to acquire the lock. + */ + public synchronized void releaseWrite() { + isWriting = false; + notifyAll(); + } +} diff --git a/desm-server/src/main/java/org/example/desm/server/controller/PollutionController.java b/desm-server/src/main/java/org/example/desm/server/controller/PollutionController.java new file mode 100644 index 0000000..6d77629 --- /dev/null +++ b/desm-server/src/main/java/org/example/desm/server/controller/PollutionController.java @@ -0,0 +1,42 @@ +package org.example.desm.server.controller; + +import org.example.desm.server.service.PollutionService; +import org.example.desm.common.dto.AverageEmission; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import static org.example.desm.common.Constant.ENDPOINT_POLLUTION; + +/** + * {@link RestController} for handling pollution related requests. + */ +@RestController +@RequestMapping(ENDPOINT_POLLUTION) +public class PollutionController { + private final PollutionService pollutionService; + + public PollutionController(PollutionService pollutionService) { + this.pollutionService = pollutionService; + } + + /** + * Endpoint to get the average emission level between two timestamps. + * + * @param t1 Start timestamp + * @param t2 End timestamp + * @return the average emission data + */ + @GetMapping + public ResponseEntity getAvgEmissionLevel( + @RequestParam long t1, + @RequestParam long t2 + ) { + double average = pollutionService.getAvgEmissionLevel(t1, t2); + average = Math.round(average * 100.0) / 100.0; + AverageEmission response = new AverageEmission(t1, t2, average); + return ResponseEntity.ok(response); + } +} diff --git a/desm-server/src/main/java/org/example/desm/server/controller/PowerPlantController.java b/desm-server/src/main/java/org/example/desm/server/controller/PowerPlantController.java new file mode 100644 index 0000000..1e75a87 --- /dev/null +++ b/desm-server/src/main/java/org/example/desm/server/controller/PowerPlantController.java @@ -0,0 +1,69 @@ +package org.example.desm.server.controller; + +import org.example.desm.server.service.PowerPlantService; +import org.example.desm.common.dto.PowerPlant; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +import static org.example.desm.common.Constant.ENDPOINT_PLANTS; + +/** + * {@link RestController} for handling power plant related requests. + */ +@RestController +@RequestMapping(ENDPOINT_PLANTS) +public class PowerPlantController { + private final PowerPlantService powerPlantService; + + public PowerPlantController(PowerPlantService powerPlantService) { + this.powerPlantService = powerPlantService; + } + + /** + * Endpoint to retrieve all registered power plants. + * + * @return a list of all power plants + */ + @GetMapping + public ResponseEntity> getAllPlants() { + List plants = powerPlantService.getAllPlants(); + return ResponseEntity.ok(plants); + } + + /** + * Endpoint to register a new power plant. + * + * @param plant the power plant to register + * @return the updated list of all power plants + */ + @PostMapping + public ResponseEntity> registerPlant( + @RequestBody PowerPlant plant + ) { + powerPlantService.registerPlant(plant); + List plants = powerPlantService.getAllPlants(); + return ResponseEntity.ok(plants); + } + + /** + * Endpoint to remove a power plant by its ID. + * + * @param plantId the ID of the power plant to delete. + * @return a confirmation response. + */ + @DeleteMapping("/{plantId}") + public ResponseEntity removePlant( + @PathVariable String plantId + ) { + powerPlantService.removePlant(plantId); + return ResponseEntity.noContent().build(); + } +} diff --git a/desm-server/src/main/java/org/example/desm/server/controller/ResetController.java b/desm-server/src/main/java/org/example/desm/server/controller/ResetController.java new file mode 100644 index 0000000..481fd6e --- /dev/null +++ b/desm-server/src/main/java/org/example/desm/server/controller/ResetController.java @@ -0,0 +1,38 @@ +package org.example.desm.server.controller; + +import org.example.desm.server.service.PollutionService; +import org.example.desm.server.service.PowerPlantService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * {@link RestController} for resetting all system data, used for testing purposes. + */ +@RestController +@RequestMapping("/reset") +public class ResetController { + private final PollutionService pollutionService; + private final PowerPlantService powerPlantService; + + public ResetController( + PollutionService pollutionService, + PowerPlantService powerPlantService + ) { + this.pollutionService = pollutionService; + this.powerPlantService = powerPlantService; + } + + /** + * Resets all stored pollution measurements and power plants. + * + * @return a confirmation message + */ + @DeleteMapping + public ResponseEntity resetAll() { + pollutionService.clearAll(); + powerPlantService.clearAll(); + return ResponseEntity.noContent().build(); + } +} \ No newline at end of file diff --git a/desm-server/src/main/java/org/example/desm/server/exception/ApiExceptions.java b/desm-server/src/main/java/org/example/desm/server/exception/ApiExceptions.java new file mode 100644 index 0000000..d622162 --- /dev/null +++ b/desm-server/src/main/java/org/example/desm/server/exception/ApiExceptions.java @@ -0,0 +1,35 @@ +package org.example.desm.server.exception; + +import lombok.Getter; +import org.example.desm.common.dto.ErrorResponse; +import org.springframework.http.HttpStatus; + +/** + * Base class for all API exceptions. + *

    + * This class extends {@link RuntimeException} and provides a method to build an {@link ErrorResponse} object. + */ +public abstract class ApiExceptions extends RuntimeException { + @Getter private final HttpStatus statusCode; + private final String error; + + public ApiExceptions(HttpStatus statusCode, String error, String message) { + super(message); + this.statusCode = statusCode; + this.error = error; + } + + /** + * Builds an {@link ErrorResponse} object using the status code, error message, and + * exception message. + * + * @return an {@link ErrorResponse} object. + */ + public ErrorResponse buildResponse() { + return new ErrorResponse( + statusCode.value(), + error, + getMessage() + ); + } +} diff --git a/desm-server/src/main/java/org/example/desm/server/exception/InvalidPowerPlantParameterException.java b/desm-server/src/main/java/org/example/desm/server/exception/InvalidPowerPlantParameterException.java new file mode 100644 index 0000000..40f2438 --- /dev/null +++ b/desm-server/src/main/java/org/example/desm/server/exception/InvalidPowerPlantParameterException.java @@ -0,0 +1,12 @@ +package org.example.desm.server.exception; + +import org.springframework.http.HttpStatus; + +/** + * Custom {@link ApiExceptions} for handling invalid power plant parameters. + */ +public class InvalidPowerPlantParameterException extends ApiExceptions { + public InvalidPowerPlantParameterException(String message) { + super(HttpStatus.BAD_REQUEST, "Invalid power plant parameter", message); + } +} diff --git a/desm-server/src/main/java/org/example/desm/server/exception/InvalidTimeIntervalException.java b/desm-server/src/main/java/org/example/desm/server/exception/InvalidTimeIntervalException.java new file mode 100644 index 0000000..662758d --- /dev/null +++ b/desm-server/src/main/java/org/example/desm/server/exception/InvalidTimeIntervalException.java @@ -0,0 +1,16 @@ +package org.example.desm.server.exception; + +import org.springframework.http.HttpStatus; + +/** + * Custom {@link ApiExceptions} for handling invalid time intervals. + */ +public class InvalidTimeIntervalException extends ApiExceptions { + public InvalidTimeIntervalException() { + super( + HttpStatus.BAD_REQUEST, + "Invalid time interval", + "Invalid time interval, start time must be less than or equal to end time." + ); + } +} diff --git a/desm-server/src/main/java/org/example/desm/server/exception/PowerPlantAlreadyExistsException.java b/desm-server/src/main/java/org/example/desm/server/exception/PowerPlantAlreadyExistsException.java new file mode 100644 index 0000000..bf5f1f3 --- /dev/null +++ b/desm-server/src/main/java/org/example/desm/server/exception/PowerPlantAlreadyExistsException.java @@ -0,0 +1,27 @@ +package org.example.desm.server.exception; + +import org.springframework.http.HttpStatus; + +/** + * Custom {@link ApiExceptions} for handling the case when a power plant already exists. + */ +public class PowerPlantAlreadyExistsException extends ApiExceptions { + private static final HttpStatus STATUS = HttpStatus.CONFLICT; + private static final String ERROR = "Plant already exists"; + + public PowerPlantAlreadyExistsException(String id) { + super( + STATUS, + ERROR, + "Power plant with ID " + id + " already exists." + ); + } + + public PowerPlantAlreadyExistsException(String address, int port) { + super( + STATUS, + ERROR, + "A plant is already registered at " + address + ":" + port + "." + ); + } +} \ No newline at end of file diff --git a/desm-server/src/main/java/org/example/desm/server/exception/ReadOperationException.java b/desm-server/src/main/java/org/example/desm/server/exception/ReadOperationException.java new file mode 100644 index 0000000..1a68e8a --- /dev/null +++ b/desm-server/src/main/java/org/example/desm/server/exception/ReadOperationException.java @@ -0,0 +1,12 @@ +package org.example.desm.server.exception; + +import org.springframework.http.HttpStatus; + +/** + * Custom {@link ApiExceptions} for handling errors related to read operations. + */ +public class ReadOperationException extends ApiExceptions { + public ReadOperationException(String message) { + super(HttpStatus.SERVICE_UNAVAILABLE, "Read error", message); + } +} diff --git a/desm-server/src/main/java/org/example/desm/server/exception/WriteOperationException.java b/desm-server/src/main/java/org/example/desm/server/exception/WriteOperationException.java new file mode 100644 index 0000000..b6fa823 --- /dev/null +++ b/desm-server/src/main/java/org/example/desm/server/exception/WriteOperationException.java @@ -0,0 +1,12 @@ +package org.example.desm.server.exception; + +import org.springframework.http.HttpStatus; + +/** + * Custom {@link ApiExceptions} for handling errors related to write operations. + */ +public class WriteOperationException extends ApiExceptions { + public WriteOperationException(String message) { + super(HttpStatus.SERVICE_UNAVAILABLE, "Write error", message); + } +} diff --git a/desm-server/src/main/java/org/example/desm/server/service/PollutionService.java b/desm-server/src/main/java/org/example/desm/server/service/PollutionService.java new file mode 100644 index 0000000..e7e844a --- /dev/null +++ b/desm-server/src/main/java/org/example/desm/server/service/PollutionService.java @@ -0,0 +1,118 @@ +package org.example.desm.server.service; + +import org.example.desm.server.ReadWriteLock; +import org.example.desm.server.exception.InvalidTimeIntervalException; +import org.example.desm.common.dto.Measurement; +import org.example.desm.server.exception.ReadOperationException; +import org.example.desm.server.exception.WriteOperationException; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.NavigableMap; +import java.util.TreeMap; + +/** + * Service class responsible for managing pollution measurements. + *

    + * Internally uses a {@link TreeMap} to store {@link Measurement} instances grouped by timestamp. + * To ensure thread safety in a concurrent environment, a custom {@link ReadWriteLock} + * is used instead of the {@code synchronized} statement on class methods. + *

    + * The read-write lock improves concurrency by allowing multiple threads to access the read method + * simultaneously, while still preventing data races during writes. This is more efficient than + * synchronizing all methods, which would force threads to wait even when only reading data. + */ +@Service +public class PollutionService { + /** + * Uses a {@link TreeMap} to store measurements by their timestamp. + * This allows efficient range queries for time-based statistics such as average emission levels. + */ + private final TreeMap> measurements = new TreeMap<>(); + + /** + * Custom ReadWriteLock used to ensure thread safety and more and more consistency. + *

    + * Read operations can happen concurrently, while write operations are exclusive. + * This avoids issues like {@link java.util.ConcurrentModificationException} + * when reading from the list during concurrent modifications. + */ + private final ReadWriteLock lock = new ReadWriteLock(); + + /** + * Return the average emission level for a given time interval. + * + * @param t1 start of the time interval (inclusive) + * @param t2 end of the time interval (inclusive) + * @return the average emission level in the specified time interval + * @throws InvalidTimeIntervalException if {@code t1} is greater than {@code t2}. + * @throws ReadOperationException if the reading thread is interrupted. + */ + public double getAvgEmissionLevel(long t1, long t2) { + if (t1 > t2) { + throw new InvalidTimeIntervalException(); + } + + try { + lock.acquireRead(); + // Either t1 or t2 is considered inclusive + NavigableMap> subMap = + measurements.subMap(t1, true, t2, true); + double sum = 0; + int count = 0; + for (List measurementsAtTime : subMap.values()) { + for (Measurement m : measurementsAtTime) { + sum += m.getValue(); + count++; + } + } + return count == 0 ? 0 : sum / count; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ReadOperationException("Unable to retrieve pollution data at this time."); + } finally { + lock.releaseRead(); + } + } + + /** + * Registers a new pollution measurements in the map. + * + * @param newMeasurements the list of pollution measurements to register. + * @throws WriteOperationException if the writing thread is interrupted. + */ + public void registerMeasurements(List newMeasurements) { + try { + lock.acquireWrite(); + for (Measurement m : newMeasurements) { + // Use computeIfAbsent to ensure that we create a new list only if it doesn't exist + measurements + .computeIfAbsent(m.getTimestamp(), k -> new ArrayList<>()) + .add(m); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new WriteOperationException("Unable to save the pollution data at this time."); + } finally { + lock.releaseWrite(); + } + } + + /** + * Clears all pollution measurements from the service. + * + * @throws WriteOperationException if the writing thread is interrupted. + */ + public void clearAll() { + try { + lock.acquireWrite(); + measurements.clear(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new WriteOperationException("Unable to clear the pollution data at this time."); + } finally { + lock.releaseWrite(); + } + } +} diff --git a/desm-server/src/main/java/org/example/desm/server/service/PowerPlantService.java b/desm-server/src/main/java/org/example/desm/server/service/PowerPlantService.java new file mode 100644 index 0000000..c1ebc92 --- /dev/null +++ b/desm-server/src/main/java/org/example/desm/server/service/PowerPlantService.java @@ -0,0 +1,191 @@ +package org.example.desm.server.service; + +import org.example.desm.server.exception.InvalidPowerPlantParameterException; +import org.example.desm.server.exception.PowerPlantAlreadyExistsException; +import org.example.desm.server.exception.ReadOperationException; +import org.example.desm.common.dto.PowerPlant; +import org.example.desm.server.ReadWriteLock; +import org.example.desm.server.exception.WriteOperationException; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Service class responsible for managing power plants. + *

    + * Internally uses a {@link Map} to store {@link PowerPlant} instances by their unique ID. + * To ensure thread safety in a concurrent environment, a custom {@link ReadWriteLock} + * is used instead of the {@code synchronized} statement on class methods. + *

    + * The read-write lock improves concurrency by allowing multiple threads to access the read method + * simultaneously, while still preventing data races during writes. This is more efficient than + * synchronizing all methods, which would force threads to wait even when only reading data. + */ +@Service +public class PowerPlantService { + /** + * A {@link Map} to store power plants by their unique IDs. + * This allows efficient retrieval and management of power plants. + */ + private final Map plants = new HashMap<>(); + + /** + * A {@link Set} to track used endpoints to prevent duplicate registrations. + * This is used to ensure that two power plants can't register with the same address and port. + */ + private final Set usedEndpoints = new HashSet<>(); + + /** + * Custom ReadWriteLock used to ensure thread safety and more consistency. + *

    + * Read operations can happen concurrently, while write operations are exclusive. + * This avoids issues like {@link java.util.ConcurrentModificationException} + * when reading from the map during concurrent modifications. + */ + private final ReadWriteLock lock = new ReadWriteLock(); + + /** + * Return the list of all registered power plants. + * + * @return an immutable snapshot list of all current power plants. + * @throws ReadOperationException if the reading thread is interrupted. + */ + public List getAllPlants() { + try { + lock.acquireRead(); + return getAllPlantsUnsafe(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ReadOperationException("Unable to retrieve power plant data at this time."); + } finally { + lock.releaseRead(); + } + } + + /** + * Registers a new power plant in the system. + * + * @param plant the power plant to register. + * @throws InvalidPowerPlantParameterException if the plant's parameters are invalid. + * @throws PowerPlantAlreadyExistsException if a plant with the same ID or same address and port already exists. + * @throws WriteOperationException if the reading thread is interrupted. + */ + public void registerPlant(PowerPlant plant) { + try { + lock.acquireWrite(); + validatePowerPlantParameters(plant); + + // Check if the plant already exists + if (plants.containsKey(plant.getId())) { + throw new PowerPlantAlreadyExistsException(plant.getId()); + } + + // Check if the endpoint is already used + String endpointKey = getEndpointKey(plant); + if (usedEndpoints.contains(endpointKey)) { + throw new PowerPlantAlreadyExistsException(plant.getAddress(), plant.getPort()); + } + + plants.put(plant.getId(), plant); + usedEndpoints.add(endpointKey); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new WriteOperationException("Unable to register the power plant at this time."); + } finally { + lock.releaseWrite(); + } + } + + /** + * Removes a power plant from the service by its ID. + * + * @param plantId the ID of the power plant to delete. + * @throws WriteOperationException if the writing thread is interrupted. + */ + public void removePlant(String plantId) { + try { + lock.acquireWrite(); + PowerPlant removedPlant = plants.remove(plantId); + + if (removedPlant != null) { + String endpointKey = getEndpointKey(removedPlant); + usedEndpoints.remove(endpointKey); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new WriteOperationException("Unable to delete the power plant at this time."); + } finally { + lock.releaseWrite(); + } + } + + /** + * Clears all registered power plants and their endpoints. + * + * @throws WriteOperationException if the writing thread is interrupted. + */ + public void clearAll() { + try { + lock.acquireWrite(); + plants.clear(); + usedEndpoints.clear(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new WriteOperationException("Unable to clear power plants at this time."); + } finally { + lock.releaseWrite(); + } + } + + /** + * Returns a list of all registered power plants without acquiring a lock. + * + * @return an immutable snapshot list of all current power plants. + */ + private List getAllPlantsUnsafe() { + return List.copyOf(plants.values()); + } + + /** + * Generates a unique key for the power plant's endpoint based on its address and port. + *

    + * This key is used to track used endpoints and prevent duplicate registrations. + * + * @param plant the power plant for which to generate the endpoint key. + * @return a {@link String} representing the unique endpoint key. + */ + private String getEndpointKey(PowerPlant plant) { + return plant.getAddress() + ":" + plant.getPort(); + } + + /** + * Validates the parameters of a {@link PowerPlant}. + *

    + * Ensures that the plant's address is not null or blank and that the port number is valid. + * + * @param plant the power plant to validate. + * @throws InvalidPowerPlantParameterException if any parameter is invalid. + */ + private void validatePowerPlantParameters(PowerPlant plant) + throws InvalidPowerPlantParameterException { + // Validate plant's ID + if (plant.getId() == null || plant.getId().isBlank()) { + throw new InvalidPowerPlantParameterException("ID cannot be null or blank."); + } + + // Validate plant's address + if (plant.getAddress() == null || plant.getAddress().isBlank()) { + throw new InvalidPowerPlantParameterException("Address cannot be null or blank."); + } + + // Validate plant's port + if (plant.getPort() < 1 || plant.getPort() > 65535) { + throw new InvalidPowerPlantParameterException( + "Invalid port number: " + plant.getPort() + ". Must be between 1 and 65535."); + } + } +} diff --git a/desm-server/src/main/resources/application.properties b/desm-server/src/main/resources/application.properties new file mode 100644 index 0000000..7412338 --- /dev/null +++ b/desm-server/src/main/resources/application.properties @@ -0,0 +1,2 @@ +spring.application.name=desm +server.port=8080 \ No newline at end of file diff --git a/desm-server/src/test/java/org/example/desm/server/DesmServerTest.java b/desm-server/src/test/java/org/example/desm/server/DesmServerTest.java new file mode 100644 index 0000000..e2430a5 --- /dev/null +++ b/desm-server/src/test/java/org/example/desm/server/DesmServerTest.java @@ -0,0 +1,23 @@ +package org.example.desm.server; + +import org.example.desm.server.controller.PollutionController; +import org.example.desm.server.controller.PowerPlantController; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +class DesmServerTest { + @Autowired + PowerPlantController powerPlantController; + @Autowired + PollutionController pollutionController; + + @Test + void contextLoads() { + assertNotNull(powerPlantController); + assertNotNull(pollutionController); + } +} diff --git a/desm-server/src/test/java/org/example/desm/server/PollutionSubscriberTest.java b/desm-server/src/test/java/org/example/desm/server/PollutionSubscriberTest.java new file mode 100644 index 0000000..6a9abea --- /dev/null +++ b/desm-server/src/test/java/org/example/desm/server/PollutionSubscriberTest.java @@ -0,0 +1,78 @@ +package org.example.desm.server; + +import com.google.gson.Gson; +import org.eclipse.paho.client.mqttv3.IMqttClient; +import org.eclipse.paho.client.mqttv3.MqttConnectOptions; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.example.desm.common.dto.Measurement; +import org.example.desm.server.service.PollutionService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.example.desm.common.Constant.TOPIC_CO2_MEASUREMENT; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class PollutionSubscriberTest { + @Mock + PollutionService pollutionService; + + @Mock + IMqttClient client; + + PollutionSubscriber pollutionSubscriber; + + @BeforeEach + void setUp() { + pollutionSubscriber = new PollutionSubscriber(client, pollutionService); + } + + @Test + void testInit() throws Exception { + doNothing().when(client).setCallback(any()); + doNothing().when(client).connect(any()); + doNothing().when(client).subscribe(anyString(), anyInt()); + + pollutionSubscriber.initConnectionAndSubscription(); + + verify(client).setCallback(pollutionSubscriber); + verify(client).connect(any(MqttConnectOptions.class)); + verify(client).subscribe(eq(TOPIC_CO2_MEASUREMENT), anyInt()); + } + + @Test + void testMessageArrived() { + List measurements = List.of( + new Measurement("id1", "co2", 400, 1000), + new Measurement("id2", "co2", 200, 2000) + ); + + Gson gson = new Gson(); + String payload = gson.toJson(measurements); + + MqttMessage mqttMessage = new MqttMessage(payload.getBytes()); + + pollutionSubscriber.messageArrived(TOPIC_CO2_MEASUREMENT, mqttMessage); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(pollutionService).registerMeasurements(captor.capture()); + + List captured = captor.getValue(); + + assertEquals(measurements.size(), captured.size()); + assertEquals(measurements, captured); + } +} \ No newline at end of file diff --git a/desm-server/src/test/java/org/example/desm/server/ReadWriteLockTest.java b/desm-server/src/test/java/org/example/desm/server/ReadWriteLockTest.java new file mode 100644 index 0000000..55a125a --- /dev/null +++ b/desm-server/src/test/java/org/example/desm/server/ReadWriteLockTest.java @@ -0,0 +1,191 @@ +package org.example.desm.server; + +import lombok.AllArgsConstructor; +import lombok.Data; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +class ReadWriteLockTest { + @Data + @AllArgsConstructor + private static class Value { + private int value; + } + + ReadWriteLock lock; + Value value; + + final int initialValue = 0; + + @BeforeEach + void setUp() { + lock = new ReadWriteLock(); + value = new Value(initialValue); + } + + @Test + void multipleReaderSimultaneously() throws InterruptedException { + int readers = 10; + CountDownLatch latch = new CountDownLatch(readers); + CyclicBarrier barrier = new CyclicBarrier(readers); + Long[] readWaitTimes = new Long[readers]; + + for (int i = 0; i < readers; i++) { + int finalI = i; + new Thread(() -> { + try { + barrier.await(); + } catch (Exception e) { + fail(e); + } + readOperation(() -> { + int read = value.getValue(); + System.out.printf("%s read: %d%n", Thread.currentThread().getName(), read); + // Simulate some work. + randomSleep(100); + assertEquals(initialValue, read); + latch.countDown(); + }, readWaitTimes, finalI); + }).start(); + } + + assertTrue(latch.await(10000, TimeUnit.MILLISECONDS)); + + System.out.println(); + printStats("Read lock", Arrays.asList(readWaitTimes)); + } + + @Test + void multipleWritersSimultaneously() throws InterruptedException { + int writers = 10; + CountDownLatch latch = new CountDownLatch(writers); + CyclicBarrier barrier = new CyclicBarrier(writers); + Long[] writeWaitTimes = new Long[writers]; + + for (int i = 0; i < writers; i++) { + int finalI = i; + new Thread(() -> { + try { + barrier.await(); + } catch (Exception e) { + fail(e); + } + writeOperation(() -> { + int val = value.getValue(); + int write = val + 1; + // Simulate some work. + randomSleep(100); + value.setValue(write); + System.out.printf("%s write: %d%n", Thread.currentThread().getName(), write); + latch.countDown(); + }, writeWaitTimes, finalI); + }).start(); + } + + assertTrue(latch.await(10000, TimeUnit.MILLISECONDS)); + assertEquals(writers, value.getValue()); + + System.out.println(); + printStats("Write lock", Arrays.asList(writeWaitTimes)); + } + + @Test + void multipleReaderAndWriterRandomArrivalTime() throws InterruptedException { + int readers = 10; + int writers = 10; + CountDownLatch readerLatch = new CountDownLatch(readers); + CountDownLatch writerLatch = new CountDownLatch(writers); + Long[] readWaitTimes = new Long[readers]; + Long[] writeWaitTimes = new Long[writers]; + + for (int i = 0; i < readers; i++) { + int finalI = i; + new Thread(() -> { + // Random delay to simulate different arrival times of the threads. + randomSleep(1000); + readOperation(() -> { + int read = value.getValue(); + int expected = (int) (writers - writerLatch.getCount()); + System.out.printf("%s read: %d%n", Thread.currentThread().getName(), read); + // Simulate some work. + randomSleep(100); + assertEquals(expected, read); + readerLatch.countDown(); + }, readWaitTimes, finalI); + }).start(); + } + + for (int i = 0; i < writers; i++) { + int finalI = i; + new Thread(() -> { + // Random delay to simulate different arrival times of the threads. + randomSleep(1000); + writeOperation(() -> { + int val = value.getValue(); + int write = val + 1; + // Simulate some work. + randomSleep(100); + value.setValue(write); + System.out.printf("%s write: %d%n", Thread.currentThread().getName(), write); + writerLatch.countDown(); + }, writeWaitTimes, finalI); + }).start(); + } + + assertTrue(readerLatch.await(10000, TimeUnit.MILLISECONDS)); + assertTrue(writerLatch.await(10000, TimeUnit.MILLISECONDS)); + assertEquals(writers, value.getValue()); + + System.out.println(); + printStats("Read lock", Arrays.asList(readWaitTimes)); + printStats("Write lock", Arrays.asList(writeWaitTimes)); + } + + private void readOperation(Runnable operation, Long[] readWaitTimes, int threadIndex) { + try { + long start = System.currentTimeMillis(); + lock.acquireRead(); + long waited = System.currentTimeMillis() - start; + readWaitTimes[threadIndex] = waited; + System.out.printf("%s waited to acquire read lock: %dms%n", Thread.currentThread().getName(), waited); + operation.run(); + lock.releaseRead(); + } catch (InterruptedException ignored) {} + } + + private void writeOperation(Runnable operation, Long[] writeWaitTimes, int threadIndex) { + try { + long start = System.currentTimeMillis(); + lock.acquireWrite(); + long waited = System.currentTimeMillis() - start; + writeWaitTimes[threadIndex] = waited; + System.out.printf("%s waited to acquire write lock: %dms%n", Thread.currentThread().getName(), waited); + operation.run(); + lock.releaseWrite(); + } catch (InterruptedException ignored) {} + } + + private void randomSleep(int maxSleepMs) { + try { + Thread.sleep((int) (Math.random() * maxSleepMs)); + } catch (InterruptedException ignored) {} + } + + private void printStats(String lockName, List waitTimes) { + long min = Collections.min(waitTimes); + long max = Collections.max(waitTimes); + double avg = waitTimes.stream().mapToLong(Long::longValue).average().orElse(0); + System.out.printf("%s wait times - min: %dms, max: %dms, avg: %.2fms%n", lockName, min, max, avg); + } +} \ No newline at end of file diff --git a/desm-server/src/test/java/org/example/desm/server/controller/PollutionControllerTest.java b/desm-server/src/test/java/org/example/desm/server/controller/PollutionControllerTest.java new file mode 100644 index 0000000..be84cb7 --- /dev/null +++ b/desm-server/src/test/java/org/example/desm/server/controller/PollutionControllerTest.java @@ -0,0 +1,47 @@ +package org.example.desm.server.controller; + +import org.example.desm.server.service.PollutionService; +import com.google.gson.Gson; +import org.example.desm.common.dto.AverageEmission; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.example.desm.common.Constant.ENDPOINT_POLLUTION; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(PollutionController.class) +class PollutionControllerTest { + @Autowired + MockMvc mockMvc; + + @MockitoBean + PollutionService pollutionService; + + Gson gson = new Gson(); + + @Test + void testGetAvgEmissionLevel() throws Exception { + long t1 = 1000; + long t2 = 2000; + double avg = 10.0; + AverageEmission response = new AverageEmission(t1, t2, avg); + + when(pollutionService.getAvgEmissionLevel(any(Long.class), any(Long.class))) + .thenReturn(avg); + + String expectedResponse = gson.toJson(response); + + mockMvc.perform(get(ENDPOINT_POLLUTION) + .param("t1", String.valueOf(t1)) + .param("t2", String.valueOf(t2))) + .andExpect(status().isOk()) + .andExpect(content().json(expectedResponse)); + } +} \ No newline at end of file diff --git a/desm-server/src/test/java/org/example/desm/server/controller/PowerPlantControllerTest.java b/desm-server/src/test/java/org/example/desm/server/controller/PowerPlantControllerTest.java new file mode 100644 index 0000000..c3580b3 --- /dev/null +++ b/desm-server/src/test/java/org/example/desm/server/controller/PowerPlantControllerTest.java @@ -0,0 +1,121 @@ +package org.example.desm.server.controller; + +import org.example.desm.server.exception.InvalidPowerPlantParameterException; +import org.example.desm.server.exception.PowerPlantAlreadyExistsException; +import org.example.desm.server.service.PowerPlantService; +import com.google.gson.Gson; +import org.example.desm.common.dto.PowerPlant; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.example.desm.common.Constant.ENDPOINT_PLANTS; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(PowerPlantController.class) +class PowerPlantControllerTest { + @Autowired + MockMvc mockMvc; + + @MockitoBean + PowerPlantService powerPlantService; + + Gson gson = new Gson(); + + @Test + void testGetAllPlants() throws Exception { + List plants = List.of( + new PowerPlant("plant-1", "localhost", 8080), + new PowerPlant("plant-2", "localhost", 8081) + ); + + when(powerPlantService.getAllPlants()).thenReturn(plants); + + String expectedResponse = gson.toJson(plants); + + mockMvc.perform(get(ENDPOINT_PLANTS)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.size()").value(plants.size())) + .andExpect(content().json(expectedResponse)); + } + + @Test + void testRegisterPlant() throws Exception { + PowerPlant plant = new PowerPlant("plant-1", "localhost", 8080); + PowerPlant newPlant = new PowerPlant("plant-2", "localhost", 8081); + List updatedList = List.of(plant, newPlant); + + doNothing().when(powerPlantService).registerPlant(any(PowerPlant.class)); + when(powerPlantService.getAllPlants()).thenReturn(updatedList); + + String requestBody = gson.toJson(newPlant); + String expectedResponse = gson.toJson(updatedList); + + mockMvc.perform( + post(ENDPOINT_PLANTS) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(content().json(expectedResponse)); + } + + @Test + void testRegisterPlantAlreadyExistsIdError() throws Exception { + PowerPlant duplicate = new PowerPlant("plant-1", "localhost", 8080); + + PowerPlantAlreadyExistsException exception = new PowerPlantAlreadyExistsException(duplicate.getId()); + + doThrow(exception).when(powerPlantService).registerPlant(any(PowerPlant.class)); + + String requestBody = gson.toJson(duplicate); + String expectedResponse = gson.toJson(exception.buildResponse()); + + mockMvc.perform(post(ENDPOINT_PLANTS) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isConflict()) + .andExpect(content().json(expectedResponse)); + } + + @Test + void testRegisterPlantInvalidParameter() throws Exception { + PowerPlant invalidPlant = new PowerPlant("plant-invalid", "", 8080); + InvalidPowerPlantParameterException exception = new InvalidPowerPlantParameterException("Error"); + + doThrow(exception).when(powerPlantService).registerPlant(any(PowerPlant.class)); + + String requestBody = gson.toJson(invalidPlant); + String expectedResponse = gson.toJson(exception.buildResponse()); + + mockMvc.perform(post(ENDPOINT_PLANTS) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isBadRequest()) + .andExpect(content().json(expectedResponse)); + } + + @Test + void testDeletePlant() throws Exception { + String plantIdToDelete = "plant-to-delete"; + + doNothing().when(powerPlantService).removePlant(eq(plantIdToDelete)); + + mockMvc.perform(delete(ENDPOINT_PLANTS + "/" + plantIdToDelete)) + .andExpect(status().isNoContent()); + } +} \ No newline at end of file diff --git a/desm-server/src/test/java/org/example/desm/server/service/PollutionServiceTest.java b/desm-server/src/test/java/org/example/desm/server/service/PollutionServiceTest.java new file mode 100644 index 0000000..9d47662 --- /dev/null +++ b/desm-server/src/test/java/org/example/desm/server/service/PollutionServiceTest.java @@ -0,0 +1,130 @@ +package org.example.desm.server.service; + +import org.example.desm.common.dto.Measurement; +import org.example.desm.server.exception.InvalidTimeIntervalException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +class PollutionServiceTest { + PollutionService pollutionService; + + @BeforeEach + void setUp() { + pollutionService = new PollutionService(); + } + + @Test + void testRegisterAndGetAverage() { + long now = 100000; + + List measurements = List.of( + new Measurement("plant-1", "Co2", 5000.0, now - 2000), + new Measurement("plant-1", "Co2", 50.0, now - 1000), + new Measurement("plant-2", "Co2", 100.0, now), + new Measurement("plant-3", "Co2", 150.0, now + 1000) + ); + + pollutionService.registerMeasurements(measurements); + + double avg = pollutionService.getAvgEmissionLevel(now - 1000, now + 1000); + assertEquals(100.0, avg, 0.001); + } + + @Test + public void testGetAvgEmissionLevelWhenNoData() { + long now = 100000; + double avg = pollutionService.getAvgEmissionLevel(now - 1000, now + 1000); + assertEquals(0.0, avg); + } + + @Test + public void testMeasurementOutsideInterval() { + long now = 100000; + + List measurements = List.of( + new Measurement("plant-2", "Co2", 100.0, now - 5000), + new Measurement("plant-2", "Co2", 50.0, now - 10000) + ); + + pollutionService.registerMeasurements(measurements); + + double avg = pollutionService.getAvgEmissionLevel(now - 1000, now + 1000); + assertEquals(0.0, avg); + } + + @Test + public void testInvalidTimeInterval() { + long now = 100000; + + assertThrows(InvalidTimeIntervalException.class, () -> + pollutionService.getAvgEmissionLevel(now + 1000, now - 1000)); + } + + @Test + void multipleGetAndRegistrationRandomArrivalTime() throws InterruptedException { + int get = 10; + int register = 10; + CountDownLatch getLatch = new CountDownLatch(get); + CountDownLatch registerLatch = new CountDownLatch(register); + int registrationCount = 3; + double[][] randomEmissions = new double[register][registrationCount]; + + for (int i = 0; i < register; i++) { + for (int j = 0; j < registrationCount; j++) { + randomEmissions[i][j] = Math.random() * 1000; + } + } + + for (int i = 0; i < get; i++) { + new Thread(() -> { + // Random delay to simulate different arrival times of the threads. + randomSleep(); + // Check if it does not throw an exception. + assertDoesNotThrow(() -> pollutionService.getAvgEmissionLevel(0, Long.MAX_VALUE)); + getLatch.countDown(); + }).start(); + } + + for (int i = 0; i < register; i++) { + int finalI = i; + new Thread(() -> { + // Random delay to simulate different arrival times of the threads. + randomSleep(); + String id = "plant-" + Thread.currentThread().getName(); + List measurements = new ArrayList<>(); + for (int j = 0; j < registrationCount; j++) { + measurements.add(new Measurement(id, "Co2", randomEmissions[finalI][j], 100)); + } + // Check if it does not throw an exception. + assertDoesNotThrow(() -> pollutionService.registerMeasurements(measurements)); + registerLatch.countDown(); + }).start(); + } + + assertTrue(getLatch.await(10000, TimeUnit.MILLISECONDS)); + assertTrue(registerLatch.await(10000, TimeUnit.MILLISECONDS)); + + double sum = 0; + for (int i = 0; i < register; i++) { + for (int j = 0; j < registrationCount; j++) { + sum += randomEmissions[i][j]; + } + } + double expectedAvg = sum / (register * registrationCount); + double avg = pollutionService.getAvgEmissionLevel(0, Long.MAX_VALUE); + assertEquals(expectedAvg, avg, 0.001); + } + + private void randomSleep() { + try { + Thread.sleep((int) (Math.random() * 1000)); + } catch (InterruptedException ignored) {} + } +} \ No newline at end of file diff --git a/desm-server/src/test/java/org/example/desm/server/service/PowerPlantServiceTest.java b/desm-server/src/test/java/org/example/desm/server/service/PowerPlantServiceTest.java new file mode 100644 index 0000000..3eaca32 --- /dev/null +++ b/desm-server/src/test/java/org/example/desm/server/service/PowerPlantServiceTest.java @@ -0,0 +1,225 @@ +package org.example.desm.server.service; + +import org.example.desm.server.exception.InvalidPowerPlantParameterException; +import org.example.desm.server.exception.PowerPlantAlreadyExistsException; +import org.example.desm.common.dto.PowerPlant; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +class PowerPlantServiceTest { + PowerPlantService powerPlantService; + + @BeforeEach + void setUp() { + powerPlantService = new PowerPlantService(); + } + + @Test + void testRegisterPowerPlant() { + PowerPlant plant = new PowerPlant("plant-1", "localhost", 8080); + + powerPlantService.registerPlant(plant); + List result = powerPlantService.getAllPlants(); + + assertEquals(1, result.size()); + assertEquals(plant, result.get(0)); + } + + @Test + void testRegisterPlantAlreadyExistsIdThrowsException() { + PowerPlant plant = new PowerPlant("plant-1", "localhost", 8080); + PowerPlant duplicate = new PowerPlant("plant-1", "localhost", 8081); + + powerPlantService.registerPlant(plant); + + assertThrows(PowerPlantAlreadyExistsException.class, () -> + powerPlantService.registerPlant(duplicate)); + } + + @Test + void testRegisterPlantAlreadyExistsEndpointThrowsException() { + PowerPlant plant = new PowerPlant("plant-1", "localhost", 8080); + PowerPlant duplicate = new PowerPlant("plant-2", "localhost", 8080); + + powerPlantService.registerPlant(plant); + + assertThrows(PowerPlantAlreadyExistsException.class, () -> + powerPlantService.registerPlant(duplicate)); + } + + @Test + void testRegisterPlantInvalidIdThrowsException() { + PowerPlant plant1 = new PowerPlant("", "localhost", 8080); + PowerPlant plant2 = new PowerPlant(null, "localhost", 8081); + + assertThrows(InvalidPowerPlantParameterException.class, () -> + powerPlantService.registerPlant(plant1)); + + assertThrows(InvalidPowerPlantParameterException.class, () -> + powerPlantService.registerPlant(plant2)); + } + + @Test + void testRegisterPlantInvalidAddressThrowsException() { + PowerPlant plant1 = new PowerPlant("plant-1", null, 8080); + PowerPlant plant2 = new PowerPlant("plant-1", "", 8081); + + assertThrows(InvalidPowerPlantParameterException.class, () -> + powerPlantService.registerPlant(plant1)); + + assertThrows(InvalidPowerPlantParameterException.class, () -> + powerPlantService.registerPlant(plant2)); + } + + @Test + void testRegisterPlantInvalidPortThrowsException() { + PowerPlant plant1 = new PowerPlant("plant-1", "localhost", 0); + PowerPlant plant2 = new PowerPlant("plant-1", "localhost", -1); + PowerPlant plant3 = new PowerPlant("plant-1", "localhost", 65536); + + assertThrows(InvalidPowerPlantParameterException.class, () -> + powerPlantService.registerPlant(plant1)); + + assertThrows(InvalidPowerPlantParameterException.class, () -> + powerPlantService.registerPlant(plant2)); + + assertThrows(InvalidPowerPlantParameterException.class, () -> + powerPlantService.registerPlant(plant3)); + } + + @Test + void testRegisterAndGetAllPlants() { + List toRegister = List.of( + new PowerPlant("plant-1", "localhost", 8080), + new PowerPlant("plant-2", "localhost", 8081), + new PowerPlant("plant-3", "localhost", 8082) + ); + + for (PowerPlant plant : toRegister) { + powerPlantService.registerPlant(plant); + } + + List allPlants = new ArrayList<>(powerPlantService.getAllPlants()); + allPlants.sort(Comparator.comparing(PowerPlant::getId)); + + assertEquals(toRegister.size(), allPlants.size()); + for (int i = 0; i < allPlants.size(); i++) { + assertEquals(toRegister.get(i), allPlants.get(i)); + } + } + + @Test + void testDeletePowerPlant() { + PowerPlant plant1 = new PowerPlant("plant-1", "localhost", 8080); + PowerPlant plant2 = new PowerPlant("plant-2", "localhost", 8081); + + powerPlantService.registerPlant(plant1); + powerPlantService.registerPlant(plant2); + + assertEquals(2, powerPlantService.getAllPlants().size()); + + powerPlantService.removePlant("plant-1"); + + List remainingPlants = powerPlantService.getAllPlants(); + assertEquals(1, remainingPlants.size()); + assertFalse(remainingPlants.contains(plant1)); + assertTrue(remainingPlants.contains(plant2)); + } + + @Test + void testDeleteNonExistentPowerPlant() { + PowerPlant plant1 = new PowerPlant("plant-1", "localhost", 8080); + powerPlantService.registerPlant(plant1); + + assertEquals(1, powerPlantService.getAllPlants().size()); + + assertDoesNotThrow(() -> powerPlantService.removePlant("non-existent-plant")); + assertEquals(1, powerPlantService.getAllPlants().size()); + } + + @Test + void multipleGetAndRegistrationRandomArrivalTime() throws InterruptedException { + int get = 10; + int register = 10; + CountDownLatch getLatch = new CountDownLatch(get); + CountDownLatch registerLatch = new CountDownLatch(register); + + for (int i = 0; i < get; i++) { + new Thread(() -> { + // Random delay to simulate different arrival times of the threads. + randomSleep(); + // Check if it does not throw an exception like ConcurrentModificationException. + assertDoesNotThrow(() -> powerPlantService.getAllPlants()); + getLatch.countDown(); + }).start(); + } + + for (int i = 0; i < register; i++) { + int finalI = i; + new Thread(() -> { + // Random delay to simulate different arrival times of the threads. + randomSleep(); + String id = "plant-" + Thread.currentThread().getName(); + PowerPlant plant = new PowerPlant(id, "localhost", 8080 + finalI); + // Check if it does not throw an exception like ConcurrentModificationException. + assertDoesNotThrow(() -> powerPlantService.registerPlant(plant)); + registerLatch.countDown(); + }).start(); + } + + assertTrue(getLatch.await(10000, TimeUnit.MILLISECONDS)); + assertTrue(registerLatch.await(10000, TimeUnit.MILLISECONDS)); + + List allPlants = powerPlantService.getAllPlants(); + assertEquals(register, allPlants.size()); + } + + @Test + void multipleRegistrationWithSameId() throws InterruptedException { + int register = 10; + CountDownLatch latchRegistration = new CountDownLatch(1); + CountDownLatch latchExceptions = new CountDownLatch(register - 1); + CyclicBarrier barrier = new CyclicBarrier(register); + + for (int i = 0; i < register; i++) { + int finalI = i; + new Thread(() -> { + try { + barrier.await(); + } catch (Exception e) { + fail(e); + } + String id = "plant-1"; + PowerPlant plant = new PowerPlant(id, "localhost", 8080 + finalI); + try { + powerPlantService.registerPlant(plant); + latchRegistration.countDown(); + } catch (PowerPlantAlreadyExistsException e) { + latchExceptions.countDown(); + } + }).start(); + } + + assertTrue(latchRegistration.await(10000, TimeUnit.MILLISECONDS)); + assertTrue(latchExceptions.await(10000, TimeUnit.MILLISECONDS)); + + List allPlants = powerPlantService.getAllPlants(); + assertEquals(1, allPlants.size()); + } + + private void randomSleep() { + try { + Thread.sleep((int) (Math.random() * 1000)); + } catch (InterruptedException ignored) {} + } +} + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c5f9201 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Thu May 08 22:35:00 CEST 2025 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..8ae2939 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,7 @@ +rootProject.name = 'desm' + +include 'desm-client' +include 'desm-core' +include 'desm-network' +include 'desm-provider' +include 'desm-server'