diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml new file mode 100644 index 0000000..1180419 --- /dev/null +++ b/.github/workflows/java.yml @@ -0,0 +1,25 @@ +name: Build Java + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v5 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + - name: Build with Gradle + run: ./gradlew build diff --git a/README.md b/README.md index d7a6ba3..e974d43 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +[![Review Assignment Due Date](https://classroom.github.com/assets/deadline-readme-button-22041afd0340ce965d47ae6ef1cefeee28c7c493a6346c4f15d667ab976d596c.svg)](https://classroom.github.com/a/qcWcnElX) # Java concurrency # Цели и задачи л/р: diff --git a/build.gradle.kts b/build.gradle.kts index bda0d97..839257a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,6 +10,12 @@ repositories { } dependencies { + implementation("com.fasterxml.jackson.core:jackson-core:2.20.0") + implementation("com.fasterxml.jackson.core:jackson-databind:2.20.0") + implementation("org.slf4j:slf4j-api:2.0.17") + implementation("ch.qos.logback:logback-classic:1.5.19") + implementation("org.jetbrains:annotations:26.0.2-1") + testImplementation(platform("org.junit:junit-bom:5.10.0")) testImplementation("org.junit.jupiter:junit-jupiter") } diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/gradlew.bat b/gradlew.bat old mode 100644 new mode 100755 diff --git a/src/main/java/org/labs/DiningProgrammersProblem.java b/src/main/java/org/labs/DiningProgrammersProblem.java new file mode 100644 index 0000000..ccb4bed --- /dev/null +++ b/src/main/java/org/labs/DiningProgrammersProblem.java @@ -0,0 +1,143 @@ +package org.labs; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.stream.IntStream; + +import org.labs.actor.Programmer; +import org.labs.actor.Waiter; +import org.labs.config.DiningProgrammersConfigProperties; +import org.labs.config.ProgrammerTimeConfigProperties; +import org.labs.resource.Spoon; +import org.labs.result.DiningProgrammersResult; +import org.labs.service.FoodProvider; +import org.labs.service.Restaurant; +import org.labs.utils.TimeWatch; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class DiningProgrammersProblem { + private static final Logger log = LoggerFactory.getLogger(DiningProgrammersProblem.class); + private static final long DEFAULT_PROGRESS_INTERVAL_SECONDS = 1; + + private final ExecutorService programmersPool; + private final ExecutorService waitersPool; + private final DiningProgrammersConfigProperties configProperties; + private final TimeWatch timeWatch; + + public DiningProgrammersProblem( + ExecutorService programmersPool, + ExecutorService waitersPool, + DiningProgrammersConfigProperties configProperties, + TimeWatch timeWatch + ) { + this.programmersPool = programmersPool; + this.waitersPool = waitersPool; + this.configProperties = configProperties; + this.timeWatch = timeWatch; + } + + public DiningProgrammersResult solve() { + var actors = createActors(); + log.info("[DiningProgrammersProblem] Simulation is initialized and ready to start"); + + timeWatch.start(); + runActors(actors.programmers, programmersPool); + runActors(actors.waiters, waitersPool); + + awaitExecution(actors.restaurant); + + return makeResults(actors, timeWatch.tick()); + } + + private DiningProgrammersActorsAndServices createActors() { + var restaurant = new Restaurant(configProperties.totalSoupPortions(), configProperties.programmersCount()); + var spoons = IntStream.range(0, configProperties.programmersCount()) + .mapToObj(Spoon::new) + .toList(); + + var programmers = IntStream.range(0, configProperties.programmersCount()) + .mapToObj(id -> + createProgrammer(id, spoons, configProperties.programmerTimeConfigProperties(), restaurant) + ).toList(); + + var waiters = IntStream.range(0, configProperties.waitersCount()) + .mapToObj(id -> + new Waiter(id, configProperties.waiterTimeConfigProperties(), restaurant) + ).toList(); + + return new DiningProgrammersActorsAndServices(programmers, waiters, restaurant); + } + + private Programmer createProgrammer( + int id, + List spoons, + ProgrammerTimeConfigProperties configProperties, + FoodProvider foodProvider + ) { + var leftSpoon = spoons.get(id); + var rightSpoon = spoons.get((id + 1) % spoons.size()); + + return new Programmer(id, leftSpoon, rightSpoon, configProperties, foodProvider); + } + + private void runActors(List actors, ExecutorService pool) { + actors.forEach(pool::submit); + } + + private void awaitExecution(Restaurant restaurant) { + programmersPool.shutdown(); + waitersPool.shutdown(); + + try { + while (!programmersPool.awaitTermination(DEFAULT_PROGRESS_INTERVAL_SECONDS, TimeUnit.SECONDS)) { + logProgress(restaurant); + } + while (!waitersPool.awaitTermination(DEFAULT_PROGRESS_INTERVAL_SECONDS, TimeUnit.SECONDS)) { + logProgress(restaurant); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("[DiningProgrammersProblem] Problem thread interrupted, problem is not solved"); + + throw new RuntimeException(e); + } + } + + private void logProgress(Restaurant restaurant) { + var elapsedSeconds = timeWatch.tick().getSeconds(); + log.info( + "[DiningProgrammersProblem] Currently running for {} seconds, available soup portions: {}," + + " plates to refill: {}", + elapsedSeconds, + restaurant.getRemainingSoupPortions(), + restaurant.getPlatesToRefillCount() + ); + } + + private DiningProgrammersResult makeResults(DiningProgrammersActorsAndServices actors, Duration totalDuration) { + List programmersPortions = actors.programmers.stream() + .map(Programmer::getConsumedSoupPortions) + .toList(); + int restaurantPortionsLeft = actors.restaurant.getRemainingSoupPortions(); + + var statistics = programmersPortions.stream().mapToDouble(it -> it).summaryStatistics(); + + return new DiningProgrammersResult( + restaurantPortionsLeft, + Double.valueOf(statistics.getSum()).intValue(), + Double.valueOf(statistics.getMin()).intValue(), + Double.valueOf(statistics.getMax()).intValue(), + statistics.getAverage(), + totalDuration.toMillis() + ); + } + + private record DiningProgrammersActorsAndServices( + List programmers, + List waiters, + Restaurant restaurant + ) { } +} diff --git a/src/main/java/org/labs/Main.java b/src/main/java/org/labs/Main.java index 9917247..5e9cdbf 100644 --- a/src/main/java/org/labs/Main.java +++ b/src/main/java/org/labs/Main.java @@ -1,7 +1,33 @@ package org.labs; +import java.util.concurrent.Executors; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.labs.config.impl.JsonDiningProgrammersConfigReader; +import org.labs.result.impl.LoggingDiningProgrammersResultPrinter; +import org.labs.utils.TimeWatch; + public class Main { + private static final String DEFAULT_CONFIG_FILE = "config.json"; + public static void main(String[] args) { - System.out.println("Hello, World!"); + var configPath = args.length > 0 ? args[0] : DEFAULT_CONFIG_FILE; + var objectMapper = new ObjectMapper(); + var configReader = new JsonDiningProgrammersConfigReader(objectMapper); + var configProperties = configReader.read(configPath); + + try (var programmersPool = Executors.newVirtualThreadPerTaskExecutor(); + var waitersPool = Executors.newVirtualThreadPerTaskExecutor() + ) { + var problem = new DiningProgrammersProblem( + programmersPool, + waitersPool, + configProperties, + new TimeWatch() + ); + var result = problem.solve(); + var resultPrinter = new LoggingDiningProgrammersResultPrinter(objectMapper); + resultPrinter.print(result); + } } -} \ No newline at end of file +} diff --git a/src/main/java/org/labs/actor/Programmer.java b/src/main/java/org/labs/actor/Programmer.java new file mode 100644 index 0000000..0c7de44 --- /dev/null +++ b/src/main/java/org/labs/actor/Programmer.java @@ -0,0 +1,130 @@ +package org.labs.actor; + +import java.util.concurrent.ThreadLocalRandom; + +import org.labs.config.ProgrammerTimeConfigProperties; +import org.labs.resource.Plate; +import org.labs.resource.Spoon; +import org.labs.service.FoodProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class Programmer implements Runnable { + private static final Logger log = LoggerFactory.getLogger(Programmer.class); + + private final int id; + private final Spoon leftSpoon; + private final Spoon rightSpoon; + private final ProgrammerTimeConfigProperties programmerTimeConfigProperties; + private final FoodProvider foodProvider; + private final ThreadLocalRandom random = ThreadLocalRandom.current(); + private int consumedSoupPortions = 0; + + public Programmer( + int id, + Spoon leftSpoon, + Spoon rightSpoon, + ProgrammerTimeConfigProperties programmerTimeConfigProperties, + FoodProvider foodProvider + ) { + this.id = id; + + if (leftSpoon.getId() < rightSpoon.getId()) { + this.leftSpoon = leftSpoon; + this.rightSpoon = rightSpoon; + } else { + this.leftSpoon = rightSpoon; + this.rightSpoon = leftSpoon; + } + + this.programmerTimeConfigProperties = programmerTimeConfigProperties; + this.foodProvider = foodProvider; + } + + public int getConsumedSoupPortions() { + return consumedSoupPortions; + } + + @Override + public void run() { + try { + haveDinner(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("[Programmer] [id={}] Was interrupted. Reason: {}", id, e.getMessage(), e); + } + log.info("[Programmer] [id={}] Finished dinner. Consumed soup portions: {}", id, consumedSoupPortions); + } + + private void haveDinner() throws InterruptedException { + while (foodProvider.isServing()) { + discussTeachers(); + + var plate = refillPlate(); + if (plate.isEmpty()) { + continue; + } + takeSpoons(); + + try { + eat(); + } finally { + releaseSpoons(); + } + } + } + + private void discussTeachers() throws InterruptedException { + var discussTimeMillis = random.nextInt( + programmerTimeConfigProperties.discussMillisMin(), + programmerTimeConfigProperties.discussMillisMax() + ); + + log.debug("[Programmer] [id={}] Will discuss teachers for {} ms", id, discussTimeMillis); + Thread.sleep(discussTimeMillis); + log.debug("[Programmer] [id={}] Stopped discussing teachers", id); + } + + private void takeSpoons() { + leftSpoon.acquire(); + log.debug("[Programmer] [id={}] [spoonId={}] Successfully acquired spoon", id, leftSpoon.getId()); + rightSpoon.acquire(); + log.debug("[Programmer] [id={}] [spoonId={}] Successfully acquired spoon", id, rightSpoon.getId()); + } + + private Plate refillPlate() throws InterruptedException { + log.debug("[Programmer] [id={}] Will ask to refill his plate", id); + + var plate = new Plate(id, consumedSoupPortions); + foodProvider.refillPlate(plate); + while (plate.isEmpty() && foodProvider.isServing()) { + plate.askToRefill(); + } + + if (!plate.isEmpty()) { + log.debug("[Programmer] [id={}] Plate was refilled", id); + } + + return plate; + } + + private void eat() throws InterruptedException { + var eatingTimeMillis = random.nextInt( + programmerTimeConfigProperties.eatingMillisMin(), + programmerTimeConfigProperties.eatingMillisMax() + ); + + log.debug("[Programmer] [id={}] Will be eating for {} ms", id, eatingTimeMillis); + Thread.sleep(eatingTimeMillis); + log.debug("[Programmer] [id={}] Stopped eating", id); + + consumedSoupPortions += 1; + } + + private void releaseSpoons() { + rightSpoon.release(); + log.debug("[Programmer] [id={}] [spoonId={}] Successfully released spoon", id, rightSpoon.getId()); + leftSpoon.release(); + log.debug("[Programmer] [id={}] [spoonId={}] Successfully released spoon", id, leftSpoon.getId()); + } +} diff --git a/src/main/java/org/labs/actor/Waiter.java b/src/main/java/org/labs/actor/Waiter.java new file mode 100644 index 0000000..b94d36e --- /dev/null +++ b/src/main/java/org/labs/actor/Waiter.java @@ -0,0 +1,71 @@ +package org.labs.actor; + +import java.util.concurrent.ThreadLocalRandom; + +import org.labs.config.WaiterTimeConfigProperties; +import org.labs.resource.Plate; +import org.labs.service.CustomerProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class Waiter implements Runnable { + private static final Logger log = LoggerFactory.getLogger(Waiter.class); + + private final int id; + private final WaiterTimeConfigProperties waiterTimeConfigProperties; + private final CustomerProvider customerProvider; + private final ThreadLocalRandom random = ThreadLocalRandom.current(); + private int platesServed = 0; + + public Waiter(int id, WaiterTimeConfigProperties waiterTimeConfigProperties, CustomerProvider customerProvider) { + this.id = id; + this.waiterTimeConfigProperties = waiterTimeConfigProperties; + this.customerProvider = customerProvider; + } + + @Override + public void run() { + try { + serveCustomers(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("[Waiter] [id={}] Was interrupted. Reason: {}", id, e.getMessage(), e); + } + log.info("[Waiter] [id={}] Finished execution. Served plates: {}", id, platesServed); + } + + private void serveCustomers() throws InterruptedException { + while (customerProvider.isServing() || customerProvider.hasPlatesToRefill()) { + var plate = customerProvider.findPlateToRefill(); + + serveCustomer(plate); + } + } + + private void serveCustomer(Plate plate) throws InterruptedException { + if (plate == null) { + return; + } + if (!customerProvider.getSoup()) { + plate.refuseToRefill(); + return; + } + + var servingTimeMillis = random.nextInt( + waiterTimeConfigProperties.servingMillisMin(), + waiterTimeConfigProperties.servingMillisMax() + ); + + log.debug( + "[Waiter] [id={}] Will be serving customer {} for {} ms", + id, + plate.getProgrammerId(), + servingTimeMillis + ); + Thread.sleep(servingTimeMillis); + plate.refill(); + + log.debug("[Waiter] [id={}] Stopped serving customer {}", id, plate.getProgrammerId()); + platesServed += 1; + } +} diff --git a/src/main/java/org/labs/config/DiningProgrammersConfigProperties.java b/src/main/java/org/labs/config/DiningProgrammersConfigProperties.java new file mode 100644 index 0000000..9fa24d8 --- /dev/null +++ b/src/main/java/org/labs/config/DiningProgrammersConfigProperties.java @@ -0,0 +1,19 @@ +package org.labs.config; + +/** + * Конфигурация симуляции + * + * @param programmersCount количество программистов + * @param waitersCount количество официантов + * @param totalSoupPortions количество порций супа + * @param programmerTimeConfigProperties настройки времени действия разработчиков {@link ProgrammerTimeConfigProperties} + * @param waiterTimeConfigProperties настройки времени работы официанта {@link WaiterTimeConfigProperties} + */ +public record DiningProgrammersConfigProperties( + int programmersCount, + int waitersCount, + int totalSoupPortions, + ProgrammerTimeConfigProperties programmerTimeConfigProperties, + WaiterTimeConfigProperties waiterTimeConfigProperties +) { +} diff --git a/src/main/java/org/labs/config/DiningProgrammersConfigReader.java b/src/main/java/org/labs/config/DiningProgrammersConfigReader.java new file mode 100644 index 0000000..f842b6c --- /dev/null +++ b/src/main/java/org/labs/config/DiningProgrammersConfigReader.java @@ -0,0 +1,5 @@ +package org.labs.config; + +public interface DiningProgrammersConfigReader { + DiningProgrammersConfigProperties read(String path); +} diff --git a/src/main/java/org/labs/config/ProgrammerTimeConfigProperties.java b/src/main/java/org/labs/config/ProgrammerTimeConfigProperties.java new file mode 100644 index 0000000..8223af1 --- /dev/null +++ b/src/main/java/org/labs/config/ProgrammerTimeConfigProperties.java @@ -0,0 +1,17 @@ +package org.labs.config; + +/** + * Настройки времени действия разработчиков + * + * @param discussMillisMin минимальное время обсуждения (мс) + * @param discussMillisMax максимальное время обсуждения (мс) + * @param eatingMillisMin минимальное время приёма пищи (мс) + * @param eatingMillisMax максимальное время приёма пищи (мс) + */ +public record ProgrammerTimeConfigProperties( + int discussMillisMin, + int discussMillisMax, + int eatingMillisMin, + int eatingMillisMax +) { +} diff --git a/src/main/java/org/labs/config/WaiterTimeConfigProperties.java b/src/main/java/org/labs/config/WaiterTimeConfigProperties.java new file mode 100644 index 0000000..03bd843 --- /dev/null +++ b/src/main/java/org/labs/config/WaiterTimeConfigProperties.java @@ -0,0 +1,13 @@ +package org.labs.config; + +/** + * Настройки времени работы официанта + * + * @param servingMillisMin минимальное время подачи блюда (мс) + * @param servingMillisMax максимальное время подачи блюда (мс) + */ +public record WaiterTimeConfigProperties( + int servingMillisMin, + int servingMillisMax +) { +} diff --git a/src/main/java/org/labs/config/impl/JsonDiningProgrammersConfigReader.java b/src/main/java/org/labs/config/impl/JsonDiningProgrammersConfigReader.java new file mode 100644 index 0000000..475e7de --- /dev/null +++ b/src/main/java/org/labs/config/impl/JsonDiningProgrammersConfigReader.java @@ -0,0 +1,36 @@ +package org.labs.config.impl; + +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.labs.config.DiningProgrammersConfigProperties; +import org.labs.config.DiningProgrammersConfigReader; + +import static java.lang.ClassLoader.getSystemResourceAsStream; + +public class JsonDiningProgrammersConfigReader implements DiningProgrammersConfigReader { + private final ObjectMapper objectMapper; + + public JsonDiningProgrammersConfigReader(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public DiningProgrammersConfigProperties read(String path) { + try { + return objectMapper.readValue(getFileContent(path), DiningProgrammersConfigProperties.class); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + private static String getFileContent(String fileName) { + try (var fileStream = Objects.requireNonNull(getSystemResourceAsStream(fileName))) { + return new String(fileStream.readAllBytes(), StandardCharsets.UTF_8); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/org/labs/resource/Plate.java b/src/main/java/org/labs/resource/Plate.java new file mode 100644 index 0000000..ead7ac6 --- /dev/null +++ b/src/main/java/org/labs/resource/Plate.java @@ -0,0 +1,50 @@ +package org.labs.resource; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class Plate { + private static final Logger log = LoggerFactory.getLogger(Plate.class); + private static final long DEFAULT_TIMEOUT_MILLIS = 1000; + + private final int programmerId; + private final int previouslyEatenSoupPortions; + private final CountDownLatch countDownLatch = new CountDownLatch(1); + private boolean isEmpty = true; + + public Plate(int programmerId, int previouslyEatenSoupPortions) { + this.programmerId = programmerId; + this.previouslyEatenSoupPortions = previouslyEatenSoupPortions; + } + + public int getProgrammerId() { + return programmerId; + } + + public boolean isEmpty() { + return isEmpty; + } + + public int previouslyEatenSoupPortions() { + return previouslyEatenSoupPortions; + } + + public void askToRefill() throws InterruptedException { + while (!countDownLatch.await(DEFAULT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) { + log.debug("[Plate] [programmerId={}] Plate is still empty, keep waiting...", programmerId); + } + } + + public void refill() { + isEmpty = false; + countDownLatch.countDown(); + } + + public void refuseToRefill() { + isEmpty = true; + countDownLatch.countDown(); + } +} diff --git a/src/main/java/org/labs/resource/Spoon.java b/src/main/java/org/labs/resource/Spoon.java new file mode 100644 index 0000000..06494af --- /dev/null +++ b/src/main/java/org/labs/resource/Spoon.java @@ -0,0 +1,25 @@ +package org.labs.resource; + +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +public class Spoon { + private final int id; + private final Lock lock = new ReentrantLock(); + + public Spoon(int id) { + this.id = id; + } + + public int getId() { + return id; + } + + public void acquire() { + lock.lock(); + } + + public void release() { + lock.unlock(); + } +} diff --git a/src/main/java/org/labs/result/DiningProgrammersResult.java b/src/main/java/org/labs/result/DiningProgrammersResult.java new file mode 100644 index 0000000..a33c479 --- /dev/null +++ b/src/main/java/org/labs/result/DiningProgrammersResult.java @@ -0,0 +1,11 @@ +package org.labs.result; + +public record DiningProgrammersResult( + int restaurantPortionsLeft, + int programmersPortionsEaten, + int minPortions, + int maxPortions, + double averagePortions, + long totalSimulationMillis +) { +} diff --git a/src/main/java/org/labs/result/DiningProgrammersResultPrinter.java b/src/main/java/org/labs/result/DiningProgrammersResultPrinter.java new file mode 100644 index 0000000..3474d59 --- /dev/null +++ b/src/main/java/org/labs/result/DiningProgrammersResultPrinter.java @@ -0,0 +1,5 @@ +package org.labs.result; + +public interface DiningProgrammersResultPrinter { + void print(DiningProgrammersResult result); +} diff --git a/src/main/java/org/labs/result/impl/LoggingDiningProgrammersResultPrinter.java b/src/main/java/org/labs/result/impl/LoggingDiningProgrammersResultPrinter.java new file mode 100644 index 0000000..64e1b62 --- /dev/null +++ b/src/main/java/org/labs/result/impl/LoggingDiningProgrammersResultPrinter.java @@ -0,0 +1,27 @@ +package org.labs.result.impl; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.labs.result.DiningProgrammersResult; +import org.labs.result.DiningProgrammersResultPrinter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class LoggingDiningProgrammersResultPrinter implements DiningProgrammersResultPrinter { + private static final Logger log = LoggerFactory.getLogger(LoggingDiningProgrammersResultPrinter.class); + private final ObjectMapper objectMapper; + + public LoggingDiningProgrammersResultPrinter(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void print(DiningProgrammersResult result) { + try { + var serializedResult = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(result); + log.info("[RESULT] {}", serializedResult); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/org/labs/service/CustomerProvider.java b/src/main/java/org/labs/service/CustomerProvider.java new file mode 100644 index 0000000..c09b6a4 --- /dev/null +++ b/src/main/java/org/labs/service/CustomerProvider.java @@ -0,0 +1,11 @@ +package org.labs.service; + +import org.jetbrains.annotations.Nullable; +import org.labs.resource.Plate; + +public interface CustomerProvider extends Service { + boolean hasPlatesToRefill(); + @Nullable + Plate findPlateToRefill(); + boolean getSoup(); +} diff --git a/src/main/java/org/labs/service/FoodProvider.java b/src/main/java/org/labs/service/FoodProvider.java new file mode 100644 index 0000000..8bffb81 --- /dev/null +++ b/src/main/java/org/labs/service/FoodProvider.java @@ -0,0 +1,7 @@ +package org.labs.service; + +import org.labs.resource.Plate; + +public interface FoodProvider extends Service { + void refillPlate(Plate plate); +} diff --git a/src/main/java/org/labs/service/Restaurant.java b/src/main/java/org/labs/service/Restaurant.java new file mode 100644 index 0000000..79e4ba2 --- /dev/null +++ b/src/main/java/org/labs/service/Restaurant.java @@ -0,0 +1,59 @@ +package org.labs.service; + +import java.util.Comparator; +import java.util.concurrent.PriorityBlockingQueue; +import java.util.concurrent.atomic.AtomicInteger; + +import org.jetbrains.annotations.Nullable; +import org.labs.resource.Plate; + +public class Restaurant implements FoodProvider, CustomerProvider { + private final AtomicInteger remainingSoupPortions; + private final PriorityBlockingQueue platesToRefill; + + public Restaurant(int totalSoupPortions, int clientsCapacity) { + if (totalSoupPortions <= 0) { + throw new IllegalArgumentException("Total soup portions in restaurant must be greater than 0"); + } + + this.remainingSoupPortions = new AtomicInteger(totalSoupPortions); + this.platesToRefill = new PriorityBlockingQueue<>( + clientsCapacity, + Comparator.comparingInt(Plate::previouslyEatenSoupPortions) + ); + } + + public int getRemainingSoupPortions() { + return remainingSoupPortions.get(); + } + + @Override + public boolean isServing() { + return remainingSoupPortions.get() > 0; + } + + @Override + public void refillPlate(Plate plate) { + platesToRefill.add(plate); + } + + public int getPlatesToRefillCount() { + return platesToRefill.size(); + } + + @Override + public boolean hasPlatesToRefill() { + return platesToRefill.peek() != null; + } + + @Override + @Nullable + public Plate findPlateToRefill() { + return platesToRefill.poll(); + } + + @Override + public boolean getSoup() { + return remainingSoupPortions.getAndUpdate(v -> v > 0 ? v - 1 : 0) > 0; + } +} diff --git a/src/main/java/org/labs/service/Service.java b/src/main/java/org/labs/service/Service.java new file mode 100644 index 0000000..d7e79fc --- /dev/null +++ b/src/main/java/org/labs/service/Service.java @@ -0,0 +1,5 @@ +package org.labs.service; + +public interface Service { + boolean isServing(); +} diff --git a/src/main/java/org/labs/utils/TimeWatch.java b/src/main/java/org/labs/utils/TimeWatch.java new file mode 100644 index 0000000..aaa3323 --- /dev/null +++ b/src/main/java/org/labs/utils/TimeWatch.java @@ -0,0 +1,17 @@ +package org.labs.utils; + +import java.time.Duration; + +public class TimeWatch { + private long startTime; + + public TimeWatch() { } + + public void start() { + startTime = System.nanoTime(); + } + + public Duration tick() { + return Duration.ofNanos(System.nanoTime() - startTime); + } +} diff --git a/src/main/resources/config.json b/src/main/resources/config.json new file mode 100644 index 0000000..daf4722 --- /dev/null +++ b/src/main/resources/config.json @@ -0,0 +1,15 @@ +{ + "programmersCount": 100, + "waitersCount": 10, + "totalSoupPortions": 100000, + "programmerTimeConfigProperties": { + "discussMillisMin": 1, + "discussMillisMax": 20, + "eatingMillisMin": 2, + "eatingMillisMax": 10 + }, + "waiterTimeConfigProperties": { + "servingMillisMin": 1, + "servingMillisMax": 5 + } +} diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..30542af --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,12 @@ + + + + + %d{dd.MM.yyyy HH:mm:ss.SSS} [%thread] %-5level %logger{20} - %msg%n + + + + + + + diff --git a/src/test/java/org/labs/DiningProgrammersProblemTest.java b/src/test/java/org/labs/DiningProgrammersProblemTest.java new file mode 100644 index 0000000..99948c3 --- /dev/null +++ b/src/test/java/org/labs/DiningProgrammersProblemTest.java @@ -0,0 +1,116 @@ +package org.labs; + +import java.time.Duration; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Assertions; +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 org.labs.config.DiningProgrammersConfigProperties; +import org.labs.config.ProgrammerTimeConfigProperties; +import org.labs.config.WaiterTimeConfigProperties; +import org.labs.result.DiningProgrammersResult; +import org.labs.utils.TimeWatch; + +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; + +public class DiningProgrammersProblemTest { + @Test + void diningProgrammers_emptyRestaurant_isError() { + Assertions.assertThrows(IllegalArgumentException.class, () -> runSimulation(7, 2, 0)); + } + + @Test + void diningProgrammers_programmersCountEqualPortionsCount_allProgrammersAteOnePortion() { + assertTimeoutPreemptively(Duration.ofSeconds(10), () -> { + var result = runSimulation(7, 2, 7); + + Assertions.assertEquals(1, result.minPortions()); + Assertions.assertEquals(1, result.maxPortions()); + + assertSuccessfulResult(result, 7); + }); + } + + @Test + void diningProgrammers_programmersCountLessThanPortionsCount_notAllProgrammersAte() { + assertTimeoutPreemptively(Duration.ofSeconds(10), () -> { + var result = runSimulation(2, 2, 1); + + Assertions.assertEquals(0, result.minPortions()); + Assertions.assertEquals(1, result.maxPortions()); + + assertSuccessfulResult(result, 1); + }); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("fairnessCases") + void diningProgrammers_checkFairness_deltaPercentLessThanThreshold( + String displayName, + int programmers, + int waiters, + int totalPortions, + double deltaPercent + ) { + assertTimeoutPreemptively(Duration.ofSeconds(15), () -> { + var result = runSimulation(programmers, waiters, totalPortions); + + int allowedDelta = computeAllowedDelta(totalPortions, programmers, deltaPercent); + int actualDelta = result.maxPortions() - result.minPortions(); + Assertions.assertTrue( + actualDelta <= allowedDelta, + "Fairness violated: actualDelta=%d, allowedDelta=%d, min=%d, max=%d" + .formatted(actualDelta, allowedDelta, result.minPortions(), result.maxPortions()) + ); + + assertSuccessfulResult(result, totalPortions); + }); + } + + static Stream fairnessCases() { + return Stream.of( + Arguments.of("Many portions, little programmers", 7, 2, 2_000, 0.03), + Arguments.of("Many programmers", 100, 4, 1_000, 0.03) + ); + } + + private static DiningProgrammersResult runSimulation(int programmers, int waiters, int totalPortions) { + DiningProgrammersConfigProperties config = new DiningProgrammersConfigProperties( + programmers, + waiters, + totalPortions, + new ProgrammerTimeConfigProperties(1, 2, 1, 2), + new WaiterTimeConfigProperties(1, 2) + ); + + ExecutorService programmersPool = Executors.newVirtualThreadPerTaskExecutor(); + ExecutorService waitersPool = Executors.newVirtualThreadPerTaskExecutor(); + + var problem = new DiningProgrammersProblem( + programmersPool, + waitersPool, + config, + new TimeWatch() + ); + return problem.solve(); + } + + private static int computeAllowedDelta(int total, int programmers, double deltaPercent) { + double avg = (double) total / (double) programmers; + int delta = (int) Math.ceil(avg * deltaPercent); + return Math.max(1, delta); + } + + private static void assertSuccessfulResult(DiningProgrammersResult result, int totalPortions) { + Assertions.assertEquals(totalPortions, result.programmersPortionsEaten()); + Assertions.assertEquals(0, result.restaurantPortionsLeft()); + + Assertions.assertTrue(result.totalSimulationMillis() >= 0); + Assertions.assertTrue(result.minPortions() <= result.maxPortions()); + } +} diff --git a/src/test/java/org/labs/config/impl/JsonDiningProgrammersConfigReaderTest.java b/src/test/java/org/labs/config/impl/JsonDiningProgrammersConfigReaderTest.java new file mode 100644 index 0000000..8cfb355 --- /dev/null +++ b/src/test/java/org/labs/config/impl/JsonDiningProgrammersConfigReaderTest.java @@ -0,0 +1,36 @@ +package org.labs.config.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.labs.config.DiningProgrammersConfigProperties; +import org.labs.config.DiningProgrammersConfigReader; +import org.labs.config.ProgrammerTimeConfigProperties; +import org.labs.config.WaiterTimeConfigProperties; + +public class JsonDiningProgrammersConfigReaderTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + private final DiningProgrammersConfigReader reader = new JsonDiningProgrammersConfigReader(objectMapper); + + @Test + void readConfig_happyPath() { + var actualProperties = reader.read("config.json"); + + var expectedProperties = new DiningProgrammersConfigProperties( + 52, + 24, + 1100, + new ProgrammerTimeConfigProperties( + 1, + 100, + 2, + 50 + ), + new WaiterTimeConfigProperties( + 5, + 10 + ) + ); + Assertions.assertEquals(expectedProperties, actualProperties); + } +} diff --git a/src/test/resources/config.json b/src/test/resources/config.json new file mode 100644 index 0000000..d66b455 --- /dev/null +++ b/src/test/resources/config.json @@ -0,0 +1,15 @@ +{ + "programmersCount": 52, + "waitersCount": 24, + "totalSoupPortions": 1100, + "programmerTimeConfigProperties": { + "discussMillisMin": 1, + "discussMillisMax": 100, + "eatingMillisMin": 2, + "eatingMillisMax": 50 + }, + "waiterTimeConfigProperties": { + "servingMillisMin": 5, + "servingMillisMax": 10 + } +}