/* * Copyright (c) 2023 AtLarge Research * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ package org.opendc.web.server.service; import jakarta.enterprise.context.ApplicationScoped; import jakarta.persistence.EntityExistsException; import java.time.Duration; import java.time.LocalDate; import java.time.temporal.TemporalAdjusters; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.opendc.web.server.model.UserAccounting; /** * Service to track the simulation budget of users. */ @ApplicationScoped public final class UserAccountingService { /** * The default simulation budget for new users. */ private final Duration simulationBudget; /** * Construct a {@link UserAccountingService} instance. * * @param simulationBudget The default simulation budget for new users. */ public UserAccountingService( @ConfigProperty(name = "opendc.accounting.simulation-budget", defaultValue = "2000m") Duration simulationBudget) { this.simulationBudget = simulationBudget; } /** * Return the {@link org.opendc.web.proto.user.UserAccounting} object for the user with the * specified userId. If the object does not exist in the database, a default value is constructed. */ public org.opendc.web.proto.user.UserAccounting getAccounting(String userId) { UserAccounting accounting = UserAccounting.findByUser(userId); if (accounting != null) { return new org.opendc.web.proto.user.UserAccounting( accounting.periodEnd, accounting.simulationTime, accounting.simulationTimeBudget); } return new org.opendc.web.proto.user.UserAccounting( getNextAccountingPeriod(LocalDate.now()), 0, (int) simulationBudget.toSeconds()); } /** * Determine whether the user with userId has any remaining simulation budget. * * @param userId The unique identifier of the user. * @return true when the user still has budget left, false otherwise. */ public boolean hasSimulationBudget(String userId) { UserAccounting accounting = UserAccounting.findByUser(userId); if (accounting == null) { return true; } return accounting.hasSimulationBudget(); } /** * Consume seconds from the simulation budget of the user with userId. * * @param userId The unique identifier of the user. * @param seconds The seconds to consume from the simulation budget. * @return true if the user has consumed his full budget or false if there is still budget * remaining. */ public boolean consumeSimulationBudget(String userId, int seconds) { LocalDate today = LocalDate.now(); LocalDate nextAccountingPeriod = getNextAccountingPeriod(today); // We need to be careful to prevent conflicts in case of concurrency // 1. First, we try to create the accounting object if it does not exist yet. This may fail if another instance // creates the object concurrently. // 2. Second, we check if the budget needs to be reset and try this atomically. // 3. Finally, we atomically consume the budget from the object // This is repeated three times in case there is a conflict for (int i = 0; i < 3; i++) { UserAccounting accounting = UserAccounting.findByUser(userId); if (accounting == null) { try { UserAccounting newAccounting = UserAccounting.create( userId, nextAccountingPeriod, (int) simulationBudget.toSeconds(), seconds); return !newAccounting.hasSimulationBudget(); } catch (EntityExistsException e) { // Conflict due to concurrency; retry } } else { boolean success; if (!today.isBefore(accounting.periodEnd)) { success = accounting.resetBudget(nextAccountingPeriod, seconds); } else { success = accounting.consumeBudget(seconds); } if (success) { return !accounting.hasSimulationBudget(); } } } throw new IllegalStateException("Failed to allocate consume budget due to conflict"); } /** * Helper method to find next accounting period. */ private static LocalDate getNextAccountingPeriod(LocalDate today) { return today.with(TemporalAdjusters.firstDayOfNextMonth()); } }