package org.cloudname.backends.consul; import org.json.JSONArray; import org.json.JSONException; import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; import javax.ws.rs.client.Client; import javax.ws.rs.client.ClientBuilder; import javax.ws.rs.core.Response; /** * A consul watch. The watch is implemented as a HTTP poll on the KV endpoint. There might be * changes that slips under the radar if someone creates, then updates a KV entry since non-existing * KV entries just return a 404 without waiting. * * @author stalehd@gmail.com */ public class ConsulWatch { /** * Listener for the value notifications. */ public interface ConsulWatchListener { /** * A value is added. * * @param key The name of the value * @param value The value */ void created(final String key, final String value); /** * A vaøie is changed. * * @param key The key name * @param value The new value */ void changed(final String key, final String value); /** * A value is removed. * * @param key The removed key */ void removed(final String key); } private static final Logger LOG = Logger.getLogger(ConsulWatch.class.getName()); private final String endpoint; private final String pathToWatch; private final Client httpClient; /** * Executor for the HTTP polling thread. */ private final Executor watchExecutor = Executors.newSingleThreadExecutor(); /** * This is the local copy of values. These are used to determine if values are added, * removed or changed. */ private final Map<String, ConsulValue> currentValues = new ConcurrentHashMap<>(); /** * This latch is set when the watch should terminate. */ private final CountDownLatch stopLatch = new CountDownLatch(1); /** * Create a new watch. * @param endpoint The Consul Agend endpoint * @param pathToWatch The path to watch */ public ConsulWatch(final String endpoint, final String pathToWatch) { this.endpoint = endpoint; this.pathToWatch = pathToWatch; httpClient = ClientBuilder.newClient(); //httpClient.register(new LoggingFilter()); } /** * Stop the watch. This will (eventually) stop all requests. */ public void stop() { stopLatch.countDown(); } /** * Start watching for changes. */ public void startWatching(final ConsulWatchListener listener) { watchExecutor.execute(() -> { int currentIndex = 0; try { while (!stopLatch.await(1, TimeUnit.MILLISECONDS)) { final Response response = httpClient .target(endpoint) .path("/v1/kv") .path(pathToWatch) .queryParam("recurse", 1) .queryParam("wait", "10s") .queryParam("index", currentIndex) .request().get(); currentIndex = Integer.parseInt(response.getHeaderString("X-Consul-Index")); switch (response.getStatus()) { case 200: try { // There's changes. Get the array of values and see if something // is new, changed or removed. New ones won't be in the // currentValues map, changed ones exist in the map but is // different, deleted ones are removed from the map. processOutput(response.readEntity(String.class), listener); } catch (final JSONException je) { LOG.log(Level.INFO, "Got exception parsing JSON for watch " + pathToWatch, je); } break; case 404: // Fake empty response processOutput("[]", listener); break; default: // Something went wrong. Stop the watch LOG.log(Level.WARNING, "Got response " + response.getStatus() + ":" + response.readEntity(String.class) + " from Consul Agent when watching " + pathToWatch + ". Stopping watch"); return; } response.close(); } } catch (final InterruptedException ie) { LOG.log(Level.WARNING, "Got InterruptedException. Stopping watch", ie); } }); } /** * Process the returned list from Consul. */ private void processOutput(final String output, final ConsulWatchListener listener) { // Keep track of the values returned by the set. final Set<String> existingValues = new HashSet<>(); existingValues.addAll(currentValues.keySet()); final JSONArray array = new JSONArray(output); for (int i = 0; i < array.length(); i++) { final ConsulValue value = ConsulValue.fromJson(array.getJSONObject(i)); final ConsulValue oldValue = currentValues.get(value.getKey()); if (oldValue == null) { invokeListener(() -> listener.created(value.getKey(), value.getValue())); currentValues.put(value.getKey(), value); } else if (oldValue.getModifyIndex() != value.getModifyIndex()) { invokeListener(() -> listener.changed(value.getKey(), value.getValue())); currentValues.put(value.getKey(), value); } existingValues.remove(value.getKey()); } // remove all other values for (final String key : existingValues) { currentValues.remove(key); invokeListener(() -> listener.removed(key)); } } /** * Invoke the listener, catching any surprise exceptions. */ private void invokeListener(final Runnable call) { try { call.run(); } catch (final RuntimeException ex) { LOG.log(Level.WARNING, "Got RuntimeException when invoking listener for path " + pathToWatch, ex); } } }