package org.cloudname.backends.consul;
import org.cloudname.core.CloudnameBackend;
import org.cloudname.core.CloudnamePath;
import org.cloudname.core.LeaseHandle;
import org.cloudname.core.LeaseListener;
import org.cloudname.core.LeaseType;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Logger;
/**
* This is a basic implementation of a CloudName backend. It uses the KV store for all data since
* the service concept doesn't fit too well with the lease concept. The KV store in conjunction
* with sessions fits nicely though. This is currently a proof-of-concept implementation tha haven't
* been tested extensively.
*
* @author stalehd@gmail.com
*/
public class ConsulBackend implements CloudnameBackend {
private static final Logger LOG = Logger.getLogger(ConsulBackend.class.getName());
final Consul consul;
private static final int SESSION_TTL = 10;
private static final int LOCK_DELAY = 0;
private final Map<CloudnamePath, ConsulSession> sessions = new ConcurrentHashMap<>();
private final Map<LeaseListener, ConsulWatch> watches = new ConcurrentHashMap<>();
private static final char SEPARATOR = '/';
private static final String CN_PREFIX = "cn";
/**
* Convert a cloudname path to a session name.
*/
private String pathToSession(final CloudnamePath path) {
return CN_PREFIX + SEPARATOR + path.join(SEPARATOR);
}
/**
* Convert a cloudname path to a KV key name.
*/
private String pathToKv(final CloudnamePath path) {
return CN_PREFIX + SEPARATOR + SEPARATOR + path.join(SEPARATOR);
}
/**
* Convert ephemeral or permanent key name into a Cloudname path.
*/
private CloudnamePath kvNameToCloudnamePath(final String name) {
final String[] elements = name.split("" + SEPARATOR);
// The first two elements are prefixes; skip those
return new CloudnamePath(Arrays.copyOfRange(elements, 2, elements.length));
}
/**
* Create new backend connected to the specified endpoint.
*
* @throws IllegalArgumentException the endpoint doesn't exist
*/
public ConsulBackend(final String consulEndpoint) {
consul = new Consul(consulEndpoint);
if (!consul.isValid()) {
throw new IllegalArgumentException("Consul endpoint " + consulEndpoint
+ " isn't a valid endpoint");
}
}
// Use a regular random value. Since this is an instance identifier which is well known
// there's no need for particular randomness.
private final Random random = new Random();
// Since Consul doesn't allow cas=0 and acquire=<session id> at the same time we'll have
// to keep track of the instance IDs we've created
private final Set<String> createdIds = new HashSet<>();
private final Object syncObject = new Object();
/**
* Get an instance ID with a random name. Ensures that the random instance id isn't used
* before by this instance. We'll just have to assume that the locks works without race
* conditions across the cluster. They probably do.
*/
private String getRandomInstanceId() {
synchronized (syncObject) {
String id = Long.toHexString(random.nextLong());
while (createdIds.contains(id)) {
id = Long.toHexString(random.nextLong());
}
createdIds.add(id);
return id;
}
}
private LeaseHandle createTemporary(final CloudnamePath path, final String data) {
// Create session with TTL set to <something> and Behavior=delete. The session isn't
// used to uniquely identify the client but to create ephemeral values in the KV store.
final ConsulSession session
= consul.createSession(pathToSession(path), SESSION_TTL, LOCK_DELAY);
// Create value in KV and set the session owner. cas = 0 to ensure no duplicates. The KV
// entry is the canonical lease
boolean leaseAcquired = false;
final AtomicReference<CloudnamePath> instancePath = new AtomicReference<>();
while (!leaseAcquired) {
instancePath.set(new CloudnamePath(path, getRandomInstanceId()));
leaseAcquired = consul.writeSessionData(
pathToKv(instancePath.get()), data, session.getId());
}
sessions.put(instancePath.get(), session);
// Optional: Create service and set the session (so that the service appears in DNS)
// health check for service is lookup in KV store. The service entry is FYI only
return new LeaseHandle() {
@Override
public boolean writeData(final String data) {
if (session.isClosed()) {
return false;
}
return consul.writeSessionData(
pathToKv(instancePath.get()), data, session.getId());
}
@Override
public CloudnamePath getLeasePath() {
if (session.isClosed()) {
return null;
}
return instancePath.get();
}
@Override
public void close() throws Exception {
// This will clear the KV entry
session.close();
sessions.remove(instancePath.get());
}
};
}
@Override
public boolean writeLeaseData(final CloudnamePath path, final String data) {
final ConsulSession session = sessions.get(path);
if (session == null) {
return false;
}
return consul.writeSessionData(pathToKv(path), data, session.getId());
}
@Override
public String readLeaseData(final CloudnamePath path) {
if (path == null) {
return null;
}
return consul.readData(pathToKv(path));
}
@Override
public LeaseHandle createLease(
final LeaseType type, final CloudnamePath path, final String data) {
switch (type) {
case PERMANENT:
if (consul.createPermanentData(pathToKv(path), data)) {
return new LeaseHandle() {
@Override
public boolean writeData(final String data) {
return writeLeaseData(path, data);
}
@Override
public CloudnamePath getLeasePath() {
return path;
}
@Override
public void close() throws Exception {
// nothing to do
}
};
}
return null;
case TEMPORARY:
return createTemporary(path, data);
default:
LOG.severe("Uknown lease type: " + type
+ " - don't know how to create that kind of lease"
+ " (path = " + path + ", data = " + data + ")");
return null;
}
}
@Override
public boolean removeLease(final CloudnamePath path) {
final String consulPath = pathToKv(path);
if (consul.readData(consulPath) == null) {
return false;
}
return consul.removePermanentData(consulPath);
}
@Override
public void addLeaseListener(final CloudnamePath leaseToObserve, final LeaseListener listener) {
final ConsulWatch watch = consul.createWatch(pathToKv(leaseToObserve));
watches.put(listener, watch);
watch.startWatching(new ConsulWatch.ConsulWatchListener() {
@Override
public void created(final String valueName, final String value) {
final CloudnamePath path = kvNameToCloudnamePath(valueName);
if (path.equals(leaseToObserve)) {
listener.leaseCreated(path, value);
}
}
@Override
public void changed(final String valueName, final String value) {
final CloudnamePath path = kvNameToCloudnamePath(valueName);
if (path.equals(leaseToObserve)) {
listener.dataChanged(path, value);
}
}
@Override
public void removed(final String valueName) {
final CloudnamePath path = kvNameToCloudnamePath(valueName);
if (path.equals(leaseToObserve)) {
listener.leaseRemoved(path);
}
}
});
}
@Override
public void addLeaseCollectionListener(
final CloudnamePath pathToObserve, final LeaseListener listener) {
final ConsulWatch watch = consul.createWatch(pathToKv(pathToObserve));
watches.put(listener, watch);
watch.startWatching(new ConsulWatch.ConsulWatchListener() {
@Override
public void created(final String valueName, final String value) {
listener.leaseCreated(kvNameToCloudnamePath(valueName), value);
}
@Override
public void changed(final String valueName, final String value) {
listener.dataChanged(kvNameToCloudnamePath(valueName), value);
}
@Override
public void removed(final String valueName) {
listener.leaseRemoved(kvNameToCloudnamePath(valueName));
}
});
}
@Override
public void removeLeaseListener(final LeaseListener listener) {
final ConsulWatch watch = watches.get(listener);
if (watch != null) {
watch.stop();
}
}
@Override
public void close() {
watches.forEach((listener, watch) -> watch.stop());
sessions.forEach((listener, session) -> session.close());
}
}