package com.mumfrey.webprefs; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; import com.mumfrey.liteloader.util.log.LiteLoaderLogger; import com.mumfrey.webprefs.exceptions.InvalidKeyException; import com.mumfrey.webprefs.exceptions.InvalidValueException; import com.mumfrey.webprefs.exceptions.ReadOnlyPreferencesException; import com.mumfrey.webprefs.framework.RequestFailureReason; import com.mumfrey.webprefs.interfaces.IWebPreferencesClient; import com.mumfrey.webprefs.interfaces.IWebPreferencesProvider; class WebPreferences extends AbstractWebPreferences { /** * The update frequency to use when operating normally, this is the * frequency that new requests will be submitted to the remote request queue */ private static final int UPDATE_FREQUENCY_TICKS = 20; // 1 second /** * Number of ticks to wait before a request is assumed to have timed out */ private static final int REQUEST_TIMEOUT_TICKS = 20 * 60; // 1 minute /** * Number of ticks to wait on any communication error (request failed at the * server, request failed to be submitted, request timed out, etc.) */ private static final int UPDATE_ERROR_SUSPEND_TICKS = 20 * 60; // 1 minute /** * Pattern for validating keys */ private static final Pattern keyPattern = Pattern.compile("^[a-z0-9_\\-\\.]{1,32}$"); /** * Preferences client, this is a delegate which is used to communicate with * the preferences provider * * @author Adam Mummery-Smith */ class Client implements IWebPreferencesClient { @Override public void onGetRequestSuccess(String uuid, Map<String, String> values) { if (!WebPreferences.this.uuid.equals(uuid)) { throw new RuntimeException("Received unsolicited response"); } WebPreferences.this.onGetRequestSuccess(values); } @Override public void onSetRequestSuccess(String uuid, Set<String> keys) { if (!WebPreferences.this.uuid.equals(uuid)) { throw new RuntimeException("Received unsolicited response"); } WebPreferences.this.onSetRequestSuccess(keys); } @Override public void onGetRequestFailed(String uuid, Set<String> keys, RequestFailureReason reason) { if (!WebPreferences.this.uuid.equals(uuid)) { throw new RuntimeException("Received unsolicited response"); } WebPreferences.this.onGetRequestFailed(keys, reason); } @Override public void onSetRequestFailed(String uuid, Set<String> keys, RequestFailureReason reason) { if (!WebPreferences.this.uuid.equals(uuid)) { throw new RuntimeException("Received unsolicited response"); } WebPreferences.this.onSetRequestFailed(keys, reason); } } /** * Preferences provider */ private final IWebPreferencesProvider provider; /** * Preferences delegate */ private final IWebPreferencesClient client; /** * Current key/value pairs */ protected final Map<String, String> prefs = new ConcurrentHashMap<String, String>(); /** * Keys which have been requested by a consumer but not requested from the * server yet */ protected final Set<String> requestedPrefs = new HashSet<String>(); /** * Keys which have been requested from the server but not received yet */ protected final Set<String> pendingPrefs = new HashSet<String>(); /** * Keys which have been set by a consumer but not sent to the server yet */ protected final Set<String> dirtyPrefs = new HashSet<String>(); /** * Concurrency lock */ protected final Object lock = new Object(); /** * True when any kind of */ protected volatile boolean dirty = false; private volatile int updateCheckTimer = 1; protected int requestTimeoutTimer = 0; WebPreferences(IWebPreferencesProvider provider, UUID uuid, boolean isPrivate, boolean isReadOnly) { this(provider, uuid.toString(), isPrivate, isReadOnly); } WebPreferences(IWebPreferencesProvider provider, String uuid, boolean isPrivate, boolean isReadOnly) { super(uuid, isPrivate, isReadOnly); this.provider = provider; this.client = new Client(); } @Override void onTick() { if (this.updateCheckTimer > 0 && --this.updateCheckTimer < 1) { this.update(); } if (this.requestTimeoutTimer > 0 && --this.requestTimeoutTimer < 1) { this.handleTimeout(); } } /** * Handle server requests on a periodic basis */ private void update() { this.updateCheckTimer = WebPreferences.UPDATE_FREQUENCY_TICKS; if (!this.dirty || !this.provider.isActive()) { return; } synchronized (this.lock) { this.dirty = false; if (this.requestedPrefs.size() > 0) { LiteLoaderLogger.debug("Preferences for " + this.uuid + " is submitting a request for " + this.requestedPrefs.size() + " requested preferences"); if (this.provider.requestGet(this.client, this.uuid, new HashSet<String>(this.requestedPrefs), this.isPrivate)) { this.requestTimeoutTimer = WebPreferences.REQUEST_TIMEOUT_TICKS; this.pendingPrefs.addAll(this.requestedPrefs); this.requestedPrefs.clear(); } else { this.dirty = true; } } } this.commit(false); } /** * Called when a pending request is deemed to have timed out */ private void handleTimeout() { this.updateCheckTimer = WebPreferences.UPDATE_ERROR_SUSPEND_TICKS; synchronized (this.lock) { this.requestedPrefs.addAll(this.pendingPrefs); this.pendingPrefs.clear(); this.dirty = true; } } /* (non-Javadoc) * @see com.mumfrey.webprefs.interfaces.IWebPreferences * #request(java.lang.String) */ @Override public void request(String key) { WebPreferences.validateKey(key); synchronized (this.lock) { this.dirty |= this.addRequestedKey(key); } } /* (non-Javadoc) * @see com.mumfrey.webprefs.interfaces.IWebPreferences * #request(java.lang.String[]) */ @Override public void request(String... keys) { if (keys.length < 1) return; if (keys.length == 1) this.request(keys[0]); synchronized (this.lock) { boolean dirty = false; for (String key : keys) { WebPreferences.validateKey(key); dirty |= this.addRequestedKey(key); } this.dirty |= dirty; } } /* (non-Javadoc) * @see com.mumfrey.webprefs.interfaces.IWebPreferences * #request(java.util.Set) */ @Override public void request(Set<String> keys) { if (keys == null || keys.size() < 1) return; synchronized (this.lock) { boolean dirty = false; for (String key : keys) { WebPreferences.validateKey(key); dirty |= this.addRequestedKey(key); } this.dirty |= dirty; } } /* (non-Javadoc) * @see com.mumfrey.webprefs.interfaces.IWebPreferences#poll() */ @Override public void poll() { synchronized (this.lock) { this.requestedPrefs.addAll(this.prefs.keySet()); this.requestedPrefs.removeAll(this.pendingPrefs); this.dirty = true; } } /* (non-Javadoc) * @see com.mumfrey.webprefs.interfaces.IWebPreferences#commit(boolean) */ @Override public void commit(boolean force) { synchronized (this.lock) { // Permanent error condition if (this.updateCheckTimer < 0) { return; } if (force) { this.dirtyPrefs.addAll(this.prefs.keySet()); } if (this.dirtyPrefs.size() > 0) { Map<String, String> outgoingPrefs = new HashMap<String, String>(); for (String key : this.dirtyPrefs) { outgoingPrefs.put(key, this.prefs.get(key)); } LiteLoaderLogger.debug("Preferences for " + this.uuid + " is submitting a SET for " + outgoingPrefs.size() + " dirty preferences"); if (this.provider.requestSet(this.client, this.uuid, outgoingPrefs, this.isPrivate)) { this.dirtyPrefs.clear(); } else { this.dirty = true; } } } } /* (non-Javadoc) * @see com.mumfrey.webprefs.interfaces.IWebPreferences * #has(java.lang.String) */ @Override public boolean has(String key) { WebPreferences.validateKey(key); return this.get(key) != null; } /* (non-Javadoc) * @see com.mumfrey.webprefs.interfaces.IWebPreferences * #get(java.lang.String) */ @Override public String get(String key) { WebPreferences.validateKey(key); // .get() can be outside of the synchronisation lock because we are using ConcurrentHashSet String value = this.prefs.get(key); if (value == null) { synchronized (this.lock) { this.dirty |= this.addRequestedKey(key); } } return value; } /* (non-Javadoc) * @see com.mumfrey.webprefs.interfaces.IWebPreferences * #get(java.lang.String, java.lang.String) */ @Override public String get(String key, String defaultValue) { WebPreferences.validateKey(key); String value = this.get(key); return value != null ? value : defaultValue; } /* (non-Javadoc) * @see com.mumfrey.webprefs.interfaces.IWebPreferences * #set(java.lang.String, java.lang.String) */ @Override public void set(String key, String value) { if (this.isReadOnly()) { throw new ReadOnlyPreferencesException("Preference collection for " + this.uuid + " is read-only"); } WebPreferences.validateKV(key, value); synchronized (this.lock) { String oldValue = this.prefs.get(key); if (value.equals(oldValue)) return; this.prefs.put(key, value); this.dirtyPrefs.add(key); this.requestedPrefs.remove(key); this.dirty = true; } } /** * Add a key to the current request set, the key will be requested from the * server on the next {@link #update()} * * @param key * @return */ private boolean addRequestedKey(String key) { if (key != null && !this.pendingPrefs.contains(key)) { this.requestedPrefs.add(key); return true; } return false; } /** * Callback from the preferences provider */ void onGetRequestSuccess(Map<String, String> values) { this.requestTimeoutTimer = 0; synchronized (this.lock) { this.prefs.putAll(values); Set<String> keys = values.keySet(); this.dirtyPrefs.removeAll(keys); this.pendingPrefs.removeAll(keys); this.requestedPrefs.removeAll(keys); } } /** * Callback from the preferences provider */ void onSetRequestSuccess(Set<String> keys) { this.requestTimeoutTimer = 0; synchronized (this.lock) { this.dirtyPrefs.removeAll(keys); this.requestedPrefs.removeAll(keys); this.dirty = (this.dirtyPrefs.size() > 0 || this.requestedPrefs.size() > 0); } } /** * Callback from the preferences provider * @param reason */ void onGetRequestFailed(Set<String> keys, RequestFailureReason reason) { this.requestTimeoutTimer = 0; this.handleFailedRequest(reason); synchronized (this.lock) { this.dirtyPrefs.addAll(keys); this.pendingPrefs.removeAll(keys); this.dirty = true; } } /** * Callback from the preferences provider * @param reason */ void onSetRequestFailed(Set<String> keys, RequestFailureReason reason) { this.requestTimeoutTimer = 0; this.handleFailedRequest(reason); synchronized (this.lock) { this.requestedPrefs.addAll(keys); this.pendingPrefs.removeAll(keys); this.dirty = true; } } /** * @param reason */ private void handleFailedRequest(RequestFailureReason reason) { if (reason.isPermanent()) { LiteLoaderLogger.debug("Halting update of preferences for " + this.uuid + " permanently because " + reason); this.updateCheckTimer = -1; } int suspendUpdateFor = WebPreferences.UPDATE_ERROR_SUSPEND_TICKS * Math.max(1, reason.getSeverity()); LiteLoaderLogger.debug("Suspending update of preferences for " + this.uuid + " for " + suspendUpdateFor + " because " + reason); this.updateCheckTimer = suspendUpdateFor; } /** * @param key */ protected static void validateKey(String key) { if (key == null || !WebPreferences.keyPattern.matcher(key).matches()) { throw new InvalidKeyException("The specified key [" + key + "] is not valid"); } } /** * @param key * @param value */ protected static void validateKV(String key, String value) { WebPreferences.validateKey(key); if (value == null || value.length() > 255) { throw new InvalidValueException("The specified value [" + value + "] for key [" + key + "] is not valid"); } } }