package io.github.lucaseasedup.logit.account; import static io.github.lucaseasedup.logit.message.MessageHelper.t; import io.github.lucaseasedup.logit.CancelledState; import io.github.lucaseasedup.logit.LogItCoreObject; import io.github.lucaseasedup.logit.common.QueuedMap; import io.github.lucaseasedup.logit.common.ReportedException; import io.github.lucaseasedup.logit.config.TimeUnit; import io.github.lucaseasedup.logit.logging.CustomLevel; import io.github.lucaseasedup.logit.session.SessionManager; import io.github.lucaseasedup.logit.storage.Infix; import io.github.lucaseasedup.logit.storage.Selector; import io.github.lucaseasedup.logit.storage.SelectorCondition; import io.github.lucaseasedup.logit.storage.SelectorConstant; import io.github.lucaseasedup.logit.storage.Storage; import io.github.lucaseasedup.logit.storage.StorageDatum; import io.github.lucaseasedup.logit.storage.StorageEntry; import io.github.lucaseasedup.logit.storage.StorageObserver; import io.github.lucaseasedup.logit.storage.StoragePinger; import io.github.lucaseasedup.logit.storage.WrapperStorage; import io.github.lucaseasedup.logit.util.CollectionUtils; import java.io.BufferedWriter; import java.io.FileWriter; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.logging.Level; import org.apache.commons.lang.StringUtils; import org.bukkit.Bukkit; import org.bukkit.scheduler.BukkitRunnable; import org.bukkit.scheduler.BukkitTask; public final class AccountManager extends LogItCoreObject implements Runnable { /** * Constructs a new {@code AccountManager}. * * @param storage the storage that this {@code AccountManager} will * operate on. * @param unit the name of a unit eligible for account storage. * @param keys the account keys present in the specified unit. */ public AccountManager( WrapperStorage storage, String unit, AccountKeys keys ) throws IOException { if (storage == null || unit == null || keys == null) throw new IllegalArgumentException(); if (!storage.isConnected()) { throw new IllegalStateException("isConnected() returned false"); } storage.addObserver(new StorageObserver() { @Override public void beforeClose() { flushBuffer(); } }); this.storage = storage; this.unit = unit; this.keys = keys; this.pinger = new StoragePinger(storage); if (getConfig("secret.yml").getBoolean("generateBufferUsageGraph")) { try { bufferUsageGraphWriter = new BufferedWriter( new FileWriter(getDataFile("bufferUsage.csv"), true) ); } catch (IOException ex) { log(Level.WARNING, ex); } } } @Override public void dispose() { storage = null; unit = null; keys = null; pinger = null; if (pingerTask != null) { pingerTask.cancel(); pingerTask = null; } if (buffer != null) { buffer.clear(); buffer = null; } if (registrationCache != null) { registrationCache.clear(); registrationCache = null; } if (bufferUsageGraphWriter != null) { try { bufferUsageGraphWriter.close(); } catch (IOException ex) { log(Level.WARNING, ex); } bufferUsageGraphWriter = null; } } /** * Internal method. Do not call directly. */ @Override public void run() { if (pingerTask == null) { pingerTask = pinger.runTaskTimer(getPlugin(), 20L, TimeUnit.MINUTES.convertTo(5, TimeUnit.TICKS)); } flushBuffer(); } /** * Selects an account with the given username from the underlying storage * unit. * * @param username the username of an account to be selected. * @param queryKeys the account keys to be returned by this query. * * @return an {@code Account} object, or {@code null} * if there was no account with the given username * or an I/O error occurred. * * @throws IllegalArgumentException if {@code username} or * {@code queryKeys} is {@code null}. * * @throws ReportedException if an I/O error occurred, * and it was reported to the logger. */ public synchronized Account selectAccount( String username, List<String> queryKeys ) { if (username == null || queryKeys == null) throw new IllegalArgumentException(); if (!queryKeys.contains(keys.username())) throw new IllegalArgumentException("Missing query key: username"); username = username.toLowerCase(); Account cachedAccount = null; // If the buffer contains some information about this account. if (buffer.containsKey(username)) { cachedAccount = buffer.get(username); // The account is known not to exist. if (cachedAccount == null) { return null; } // The account exists in the buffer. else { // All the query keys can be found in the cached entry. if (CollectionUtils.isSubset(queryKeys, cachedAccount.getEntry().getKeys())) { return cachedAccount; } // Some keys need to be fetched from the storage // in order to fulfill the selection request. else { // Remove the keys that have already been fetched; // we only need those that hasn't been. queryKeys = new ArrayList<>(queryKeys); queryKeys.removeAll(cachedAccount.getEntry().getKeys()); // If the username key has been removed // (actually, it is always the case), // then put it back into the key list. if (!queryKeys.contains(keys.username())) { queryKeys.add(keys.username()); } } } } List<StorageEntry> entries = null; try { entries = storage.selectEntries( unit, queryKeys, new SelectorCondition( keys.username(), Infix.EQUALS, username ) ); } catch (IOException ex) { log(Level.WARNING, ex); ReportedException.throwNew(ex); } // If an I/O error occurred, return null. if (entries == null) return null; // Cache registration status. registrationCache.put(username, !entries.isEmpty()); // If no such account exists in the storage, // mark it in the buffer as non-existing and return null. if (entries.isEmpty()) { buffer.put(username, null); return null; } // If the account is just partially cached, // fill the missing keys with the values fetched from the storage. if (cachedAccount != null) { for (StorageDatum datum : entries.get(0)) { if (!cachedAccount.getEntry().containsKey(datum.getKey())) { cachedAccount.getEntry().put( datum.getKey(), datum.getValue() ); cachedAccount.getEntry().clearKeyDirty(datum.getKey()); } } } // If there was no cached account in the buffer, // create a new Account object for it and put it into the buffer. if (!buffer.containsKey(username)) { cachedAccount = new Account(entries.get(0), false); buffer.put(username, cachedAccount); } return cachedAccount; } public synchronized List<Account> selectAccounts( List<String> queryKeys, Selector selector ) { if (queryKeys == null || selector == null) throw new IllegalArgumentException(); if (!queryKeys.contains(keys.username())) throw new IllegalArgumentException("Missing query key: username"); List<StorageEntry> entries = null; try { entries = storage.selectEntries(unit, queryKeys, selector); } catch (IOException ex) { log(Level.WARNING, ex); ReportedException.throwNew(ex); } if (entries == null) return null; List<Account> accounts = new ArrayList<>(entries.size()); for (StorageEntry entry : entries) { String username = entry.get(keys().username()).toLowerCase(); registrationCache.put(username, true); if (buffer.get(username) != null) { for (StorageDatum datum : buffer.get(username).getEntry()) { entry.put(datum.getKey(), datum.getValue()); } } Account account = new Account(entry, false); if (buffer.get(username) == null) { buffer.put(username, account); } accounts.add(account); } return accounts; } public boolean isRegistered( String username, RegistrationFetchMode fetchMode ) { if (StringUtils.isBlank(username) || fetchMode == null) throw new IllegalArgumentException(); username = username.toLowerCase(); if (fetchMode == RegistrationFetchMode.STORAGE_ONLY) { return fetchRegistrationStatus(username); } else { Boolean registered = registrationCache.get(username); if (registered == null) { if (fetchMode == RegistrationFetchMode.STORAGE_FALLBACK) { return fetchRegistrationStatus(username); } else if (fetchMode == RegistrationFetchMode.CACHE_ELSE_TRUE) { return true; } else if (fetchMode == RegistrationFetchMode.CACHE_ELSE_FALSE) { return false; } else { throw new IllegalArgumentException( "Unsupported RegistrationFetchMode: " + fetchMode ); } } else { return registered; } } } public boolean isRegistered(String username) { return isRegistered(username, RegistrationFetchMode.STORAGE_ONLY); } private boolean fetchRegistrationStatus(String username) { Account account; if (buffer.containsKey(username)) { account = buffer.get(username); } else { account = selectAccount( username, Arrays.asList(keys.username()) ); } return account != null; } /** * Returns all registered usernames in this {@code AccountManager}. * * @return A {@code Set} containing all registered usernames lowercase, or * {@code null} if an I/O error occurred. * * @throws ReportedException * If an I/O error occurred, and it was reported to the logger. */ public Set<String> getRegisteredUsernames() { List<Account> accounts = selectAccounts( Arrays.asList( keys().username() ), new SelectorConstant(true) ); if (accounts == null) return null; Set<String> usernames = new LinkedHashSet<>(accounts.size()); for (Account account : accounts) { usernames.add(account.getUsername()); } return usernames; } public synchronized CancelledState insertAccount(Account account) { if (account == null) throw new IllegalArgumentException(); AccountEvent event = new AccountInsertEvent(account.getEntry()); Bukkit.getPluginManager().callEvent(event); if (event.isCancelled()) return CancelledState.CANCELLED; try { StorageEntry entry = account.getEntry(); storage.addEntry(unit, entry); for (StorageDatum datum : entry) { entry.clearKeyDirty(datum.getKey()); } buffer.put(account.getUsername(), account); log(Level.FINE, t("createAccount.success.log") .replace("{0}", account.getUsername())); event.executeSuccessTasks(); } catch (IOException ex) { log(Level.WARNING, t("createAccount.fail.log") .replace("{0}", account.getUsername()), ex); event.executeFailureTasks(); ReportedException.throwNew(ex); } return CancelledState.NOT_CANCELLED; } public synchronized void insertAccounts(Account... accounts) { if (accounts == null) throw new IllegalArgumentException(); try { storage.setAutobatchEnabled(true); for (Account account : accounts) { insertAccount(account); } storage.executeBatch(); storage.clearBatch(); } catch (IOException ex) { log(Level.WARNING, ex); ReportedException.throwNew(ex); } finally { storage.setAutobatchEnabled(false); } } public synchronized void renameAccount(String username, String newUsername) { if (StringUtils.isBlank(username) || StringUtils.isBlank(newUsername)) { throw new IllegalArgumentException(); } username = username.toLowerCase(); newUsername = newUsername.toLowerCase(); try { storage.updateEntries(unit, new StorageEntry.Builder() .put(keys().username(), newUsername) .put(keys().display_name(), "") .build(), new SelectorCondition( keys.username(), Infix.EQUALS, username ) ); Account bufferedAccount = buffer.remove(username); if (bufferedAccount != null) { bufferedAccount.getEntry().put(keys().username(), newUsername); if (buffer.get(newUsername) != null) { buffer.get(newUsername).setEntry( bufferedAccount.getEntry() ); } buffer.put(newUsername, bufferedAccount); } } catch (IOException ex) { log(Level.WARNING, ex); ReportedException.throwNew(ex); } } /** * Removes an account with the given username from the underlying storage * unit. * * <p> Removing an account does not entail logging out the corresponding * player. To log out a player, use {@link SessionManager#endSession}. * * <p> This method emits the {@code AccountRemoveEvent} event. * * @param username the username of an account to be removed. * * @return a {@code CancellableState} indicating whether this operation * has been cancelled by one of the {@code AccountRemoveEvent} * handlers. * * @throws IllegalArgumentException if {@code username} is {@code null} * or blank. * * @throws ReportedException if an I/O error occurred, * and it was reported to the logger. */ public synchronized CancelledState removeAccount(String username) { if (StringUtils.isBlank(username)) throw new IllegalArgumentException(); username = username.toLowerCase(); AccountEvent event = new AccountRemoveEvent(username); Bukkit.getPluginManager().callEvent(event); if (event.isCancelled()) return CancelledState.CANCELLED; try { storage.removeEntries( unit, new SelectorCondition( keys.username(), Infix.EQUALS, username ) ); buffer.put(username, null); log(Level.WARNING, t("removeAccount.success.log") .replace("{0}", username)); event.executeSuccessTasks(); } catch (IOException ex) { log(Level.WARNING, t("removeAccount.fail.log") .replace("{0}", username), ex); event.executeFailureTasks(); ReportedException.throwNew(ex); } return CancelledState.NOT_CANCELLED; } public synchronized void removeAccounts(String... usernames) { if (usernames == null) throw new IllegalArgumentException(); try { storage.setAutobatchEnabled(true); for (String username : usernames) { removeAccount(username); } storage.executeBatch(); storage.clearBatch(); } catch (IOException ex) { log(Level.WARNING, ex); ReportedException.throwNew(ex); } finally { storage.setAutobatchEnabled(false); } } private void flushBuffer() { if (buffer == null || buffer.isEmpty()) return; if (storage == null) return; Map<String, Account> dirtyAccounts = new HashMap<>(); QueuedMap<String, Account> ignoredAccounts = new QueuedMap<>(); QueuedMap<String, StorageEntry> dirtyEntries = new QueuedMap<>(); while (!buffer.isEmpty()) { Map.Entry<String, Account> e = buffer.remove(); String username = e.getKey(); Account account = e.getValue(); if (account == null) continue; if (account.isBufferLocked()) { ignoredAccounts.put(username, account); continue; } StorageEntry dirtyEntry = account.getEntry().copyDirty(); if (!dirtyEntry.getKeys().isEmpty()) { dirtyAccounts.put(username, account); dirtyEntries.put(username, dirtyEntry); } } // Restore buffer-locked accounts. buffer.putAll(ignoredAccounts); if (bufferUsageGraphWriter != null) { long elapsedTicks = getCore().getGlobalClock().getElapsed(); try { if (!bufferUsageGraphTouched) { bufferUsageGraphWriter.newLine(); bufferUsageGraphWriter.write( ":" + System.currentTimeMillis() ); bufferUsageGraphWriter.newLine(); bufferUsageGraphTouched = true; } bufferUsageGraphWriter.write( elapsedTicks + "," + dirtyEntries.size() ); bufferUsageGraphWriter.newLine(); bufferUsageGraphWriter.flush(); } catch (IOException ex) { log(Level.WARNING, ex); } } if (dirtyEntries.isEmpty()) return; log(CustomLevel.INTERNAL, "AccountManager#flushBuffer() {" + "dirtyEntries.size() = " + dirtyEntries.size() + "}"); try { storage.setAutobatchEnabled(true); for (Map.Entry<String, StorageEntry> e : dirtyEntries.entrySet()) { try { storage.updateEntries( unit, e.getValue(), new SelectorCondition( keys.username(), Infix.EQUALS, e.getKey().toLowerCase() ) ); dirtyAccounts.get(e.getKey()).runSaveCallbacks(true); } catch (IOException ex) { log(Level.WARNING, ex); dirtyAccounts.get(e.getKey()).runSaveCallbacks(false); } } storage.executeBatch(); storage.clearBatch(); } catch (IOException ex) { log(Level.WARNING, ex); } finally { storage.setAutobatchEnabled(false); } log(CustomLevel.INTERNAL, "end-of #flushBuffer()"); } private void discardBuffer() { buffer.clear(); } public Storage getStorage() { return storage; } public String getUnit() { return unit; } public AccountKeys getKeys() { return keys; } public static enum RegistrationFetchMode { CACHE_ELSE_TRUE, CACHE_ELSE_FALSE, STORAGE_FALLBACK, STORAGE_ONLY; } private Storage storage; private String unit; private AccountKeys keys; private BukkitRunnable pinger; private BukkitTask pingerTask; private QueuedMap<String, Account> buffer = new QueuedMap<>(); private Map<String, Boolean> registrationCache = new HashMap<>(); private BufferedWriter bufferUsageGraphWriter; private boolean bufferUsageGraphTouched = false; }