/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.camel.component.zookeepermaster.group.internal;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.UUID;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.camel.component.zookeepermaster.group.Group;
import org.apache.camel.component.zookeepermaster.group.GroupListener;
import org.apache.camel.component.zookeepermaster.group.NodeState;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.listen.ListenerContainer;
import org.apache.curator.framework.state.ConnectionState;
import org.apache.curator.framework.state.ConnectionStateListener;
import org.apache.curator.utils.EnsurePath;
import org.apache.curator.utils.ZKPaths;
import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.KeeperException;
import org.apache.zookeeper.WatchedEvent;
import org.apache.zookeeper.Watcher;
import org.apache.zookeeper.data.Stat;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* <p>A utility that attempts to keep all data from all children of a ZK path locally cached. This class
* will watch the ZK path, respond to update/create/delete events, pull down the data, etc. You can
* register a listener that will get notified when changes occur.</p>
* <p/>
* <p><b>IMPORTANT</b> - it's not possible to stay transactionally in sync. Users of this class must
* be prepared for false-positives and false-negatives. Additionally, always use the version number
* when updating data to avoid overwriting another process' change.</p>
*/
public class ZooKeeperGroup<T extends NodeState> implements Group<T> {
private static final Logger LOG = LoggerFactory.getLogger(ZooKeeperGroup.class);
private static ObjectMapper mapper = new ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
private final Class<T> clazz;
private final CuratorFramework client;
private final String path;
private final ExecutorService executorService;
private final EnsurePath ensurePath;
private final BlockingQueue<Operation> operations = new LinkedBlockingQueue<Operation>();
private final ListenerContainer<GroupListener<T>> listeners = new ListenerContainer<GroupListener<T>>();
private final ConcurrentMap<String, ChildData<T>> currentData = new ConcurrentHashMap<>();
private final AtomicBoolean started = new AtomicBoolean();
private final AtomicBoolean connected = new AtomicBoolean();
private final SequenceComparator sequenceComparator = new SequenceComparator();
private final String uuid = UUID.randomUUID().toString();
private volatile String id;
// to help detecting whether ZK Group update failed
private final AtomicBoolean creating = new AtomicBoolean();
// flag indicating that ephemeral node could be created in registry, but exact sequence ID is uknown
// this status means we may have (temporary - for the period of ZK session) duplication of nodes
private final AtomicBoolean unstable = new AtomicBoolean();
private volatile T state;
private final Watcher childrenWatcher = new Watcher() {
@Override
public void process(WatchedEvent event) {
if (event.getType() != Event.EventType.None) {
// only interested in real change events, eg no refresh on Keeper.Disconnect
offerOperation(new RefreshOperation(ZooKeeperGroup.this, RefreshMode.STANDARD));
}
}
};
private final Watcher dataWatcher = new Watcher() {
@Override
public void process(WatchedEvent event) {
try {
if (event.getType() == Event.EventType.NodeDeleted) {
remove(event.getPath());
} else if (event.getType() == Event.EventType.NodeDataChanged) {
offerOperation(new GetDataOperation(ZooKeeperGroup.this, event.getPath()));
}
} catch (Exception e) {
handleException(e);
}
}
};
private final ConnectionStateListener connectionStateListener = new ConnectionStateListener() {
@Override
public void stateChanged(CuratorFramework client, ConnectionState newState) {
handleStateChange(newState);
}
};
/**
* @param client the client
* @param path path to watch
*/
public ZooKeeperGroup(CuratorFramework client, String path, Class<T> clazz) {
this(client, path, clazz, Executors.newSingleThreadExecutor());
}
/**
* @param client the client
* @param path path to watch
* @param threadFactory factory to use when creating internal threads
*/
public ZooKeeperGroup(CuratorFramework client, String path, Class<T> clazz, ThreadFactory threadFactory) {
this(client, path, clazz, Executors.newSingleThreadExecutor(threadFactory));
}
/**
* @param client the client
* @param path path to watch
* @param executorService ExecutorService to use for the ZooKeeperGroup's background thread
*/
public ZooKeeperGroup(CuratorFramework client, String path, Class<T> clazz, final ExecutorService executorService) {
LOG.info("Creating ZK Group for path \"" + path + "\"");
this.client = client;
this.path = path;
this.clazz = clazz;
this.executorService = executorService;
ensurePath = client.newNamespaceAwareEnsurePath(path);
}
/**
* Start the cache. The cache is not started automatically. You must call this method.
*/
public void start() {
LOG.info("Starting ZK Group for path: {}", path);
if (started.compareAndSet(false, true)) {
connected.set(client.getZookeeperClient().isConnected());
if (isConnected()) {
handleStateChange(ConnectionState.CONNECTED);
}
client.getConnectionStateListenable().addListener(connectionStateListener);
executorService.execute(new Runnable() {
@Override
public void run() {
mainLoop();
}
});
}
}
/**
* Close/end the cache
*
* @throws IOException errors
*/
@Override
public void close() throws IOException {
LOG.debug(this + ".close, connected:" + connected);
if (started.compareAndSet(true, false)) {
client.getConnectionStateListenable().removeListener(connectionStateListener);
executorService.shutdownNow();
try {
executorService.awaitTermination(5, TimeUnit.SECONDS);
} catch (InterruptedException e) {
throw (IOException) new InterruptedIOException().initCause(e);
}
try {
doUpdate(null);
if (isConnected()) {
callListeners(GroupListener.GroupEvent.DISCONNECTED);
}
} catch (Exception e) {
handleException(e);
}
listeners.clear();
mapper.getTypeFactory().clearCache();
mapper = new ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
client.clearWatcherReferences(childrenWatcher);
client.clearWatcherReferences(dataWatcher);
}
}
@Override
public boolean isConnected() {
return connected.get();
}
@Override
public void add(GroupListener<T> listener) {
listeners.addListener(listener);
}
@Override
public void remove(GroupListener<T> listener) {
listeners.removeListener(listener);
}
@Override
public void update(T state) {
T oldState = this.state;
this.state = state;
if (started.get()) {
boolean update = state == null && oldState != null
|| state != null && oldState == null
|| !Arrays.equals(encode(state), encode(oldState));
if (update) {
offerOperation(new CompositeOperation(
new RefreshOperation(this, RefreshMode.FORCE_GET_DATA_AND_STAT),
new UpdateOperation<T>(this, state)
));
}
}
}
protected void doUpdate(T state) throws Exception {
if (LOG.isTraceEnabled()) {
// state.toString() invokes Jackson ObjectMapper serialization
LOG.trace(this + " doUpdate, state:" + state + " id:" + id);
}
if (state == null) {
if (id != null) {
try {
if (isConnected()) {
client.delete().guaranteed().forPath(id);
unstable.set(false);
}
} catch (KeeperException.NoNodeException e) {
// Ignore
} finally {
id = null;
}
} else if (creating.get()) {
LOG.warn("Ephemeral node could be created in the registry, but ZooKeeper group didn't record its id");
unstable.set(true);
}
} else if (isConnected()) {
// We could have created the sequence, but then have crashed and our entry is already registered.
// However, we ignore old ephemeral nodes, and create new ones. We can have double nodes for a bit,
// but the old ones should be deleted by the server when session is invalidated.
// See: https://issues.jboss.org/browse/FABRIC-1238
if (id == null) {
id = createEphemeralNode(state);
} else {
try {
updateEphemeralNode(state);
} catch (KeeperException.NoNodeException e) {
id = createEphemeralNode(state);
}
}
}
}
private String createEphemeralNode(T state) throws Exception {
state.uuid = uuid;
creating.set(true);
String pathId = client.create().creatingParentsIfNeeded()
.withMode(CreateMode.EPHEMERAL_SEQUENTIAL)
.forPath(path + "/0", encode(state));
creating.set(false);
unstable.set(false);
if (LOG.isTraceEnabled()) {
// state.toString() invokes Jackson ObjectMapper serialization
LOG.trace(this + ", state:" + state + ", new ephemeralSequential path:" + pathId);
}
prunePartialState(state, pathId);
state.uuid = null;
return pathId;
}
private void updateEphemeralNode(T state) throws Exception {
state.uuid = uuid;
client.setData().forPath(id, encode(state));
state.uuid = null;
}
// remove ephemeral sequential nodes created on server but not visible on client
private void prunePartialState(final T ourState, final String pathId) throws Exception {
if (ourState.uuid != null) {
clearAndRefresh(true, true);
List<ChildData<T>> children = new ArrayList<ChildData<T>>(currentData.values());
for (ChildData<T> child : children) {
if (ourState.uuid.equals(child.getNode().uuid) && !child.getPath().equals(pathId)) {
LOG.debug("Deleting partially created znode: " + child.getPath());
client.delete().guaranteed().forPath(child.getPath());
}
}
}
}
@Override
public Map<String, T> members() {
List<ChildData<T>> children = getActiveChildren();
Collections.sort(children, sequenceComparator);
Map<String, T> members = new LinkedHashMap<String, T>();
for (ChildData<T> child : children) {
members.put(child.getPath(), child.getNode());
}
return members;
}
@Override
public boolean isMaster() {
List<ChildData<T>> children = getActiveChildren();
Collections.sort(children, sequenceComparator);
return !children.isEmpty() && children.get(0).getPath().equals(id);
}
@Override
public T master() {
List<ChildData<T>> children = getActiveChildren();
Collections.sort(children, sequenceComparator);
if (children.isEmpty()) {
return null;
}
return children.get(0).getNode();
}
@Override
public List<T> slaves() {
List<ChildData<T>> children = getActiveChildren();
Collections.sort(children, sequenceComparator);
List<T> slaves = new ArrayList<T>();
for (int i = 1; i < children.size(); i++) {
slaves.add(children.get(i).getNode());
}
return slaves;
}
/**
* Filter stale nodes and return only active children from the current data.
*
* @return list of active children and data
*/
protected List<ChildData<T>> getActiveChildren() {
Map<String, ChildData<T>> filtered = new HashMap<>();
for (ChildData<T> child : currentData.values()) {
T node = child.getNode();
if (!filtered.containsKey(node.getContainer())
|| filtered.get(node.getContainer()).getPath().compareTo(child.getPath()) < 0) {
filtered.put(node.getContainer(), child);
}
}
return new ArrayList<>(filtered.values());
}
@Override
public T getLastState() {
return this.state;
}
public SequenceComparator getSequenceComparator() {
return sequenceComparator;
}
/**
* Return the cache listenable
*
* @return listenable
*/
public ListenerContainer<GroupListener<T>> getListenable() {
return listeners;
}
/**
* Return the current data. There are no guarantees of accuracy. This is
* merely the most recent view of the data. The data is returned in sorted order.
*
* @return list of children and data
*/
public List<ChildData> getCurrentData() {
List<ChildData> answer = new ArrayList<>();
answer.addAll(currentData.values());
return answer;
}
/**
* Used for testing purpose
*/
void putCurrentData(String key, ChildData value) {
currentData.put(key, value);
}
/**
* Return the current data for the given path. There are no guarantees of accuracy. This is
* merely the most recent view of the data. If there is no child with that path, <code>null</code>
* is returned.
*
* @param fullPath full path to the node to check
* @return data or null
*/
public ChildData getCurrentData(String fullPath) {
return currentData.get(fullPath);
}
/**
* Clear out current data and begin a new query on the path
*
* @throws Exception errors
*/
public void clearAndRefresh() throws Exception {
clearAndRefresh(false, false);
}
/**
* Clear out current data and begin a new query on the path
*
* @param force - whether to force clear and refresh to trigger updates
* @param sync - whether to run this synchronously (block current thread) or asynchronously
* @throws Exception errors
*/
public void clearAndRefresh(boolean force, boolean sync) throws Exception {
RefreshMode mode = force ? RefreshMode.FORCE_GET_DATA_AND_STAT : RefreshMode.STANDARD;
currentData.clear();
if (sync) {
this.refresh(mode);
} else {
offerOperation(new RefreshOperation(this, mode));
}
}
/**
* Clears the current data without beginning a new query and without generating any events
* for listeners.
*/
public void clear() {
currentData.clear();
}
enum RefreshMode {
STANDARD,
FORCE_GET_DATA_AND_STAT
}
void refresh(final RefreshMode mode) throws Exception {
try {
ensurePath.ensure(client.getZookeeperClient());
List<String> children = client.getChildren().usingWatcher(childrenWatcher).forPath(path);
Collections.sort(children, new Comparator<String>() {
@Override
public int compare(String left, String right) {
return left.compareTo(right);
}
});
processChildren(children, mode);
} catch (Exception e) {
handleException(e);
}
}
void callListeners(final GroupListener.GroupEvent event) {
listeners.forEach(listener -> {
try {
listener.groupEvent(ZooKeeperGroup.this, event);
} catch (Exception e) {
handleException(e);
}
return null;
}
);
}
void getDataAndStat(final String fullPath) throws Exception {
Stat stat = new Stat();
byte[] data = client.getData().storingStatIn(stat).usingWatcher(dataWatcher).forPath(fullPath);
applyNewData(fullPath, KeeperException.Code.OK.intValue(), stat, data);
}
/**
* Default behavior is just to log the exception
*
* @param e the exception
*/
protected void handleException(Throwable e) {
if (e instanceof IllegalStateException && "Client is not started".equals(e.getMessage())) {
LOG.debug("", e);
} else {
LOG.error("", e);
}
}
protected void remove(String fullPath) {
ChildData data = currentData.remove(fullPath);
if (data != null) {
offerOperation(new EventOperation(this, GroupListener.GroupEvent.CHANGED));
}
}
private void handleStateChange(ConnectionState newState) {
switch (newState) {
case SUSPENDED:
case LOST: {
connected.set(false);
clear();
EventOperation op = new EventOperation(this, GroupListener.GroupEvent.DISCONNECTED);
op.invoke();
break;
}
case CONNECTED:
case RECONNECTED: {
connected.set(true);
offerOperation(new CompositeOperation(
new RefreshOperation(this, RefreshMode.FORCE_GET_DATA_AND_STAT),
new UpdateOperation<T>(this, state),
new EventOperation(this, GroupListener.GroupEvent.CONNECTED)
));
break;
}
default:
// noop
}
}
private void processChildren(List<String> children, RefreshMode mode) throws Exception {
List<String> fullPaths = children.stream().map(c -> ZKPaths.makePath(path, c)).collect(Collectors.toList());
Set<String> removedNodes = new HashSet<>(currentData.keySet());
removedNodes.removeAll(fullPaths);
for (String fullPath : removedNodes) {
remove(fullPath);
}
for (String name : children) {
String fullPath = ZKPaths.makePath(path, name);
if ((mode == RefreshMode.FORCE_GET_DATA_AND_STAT) || !currentData.containsKey(fullPath)) {
try {
getDataAndStat(fullPath);
} catch (KeeperException.NoNodeException ignore) {
}
}
}
}
private void applyNewData(String fullPath, int resultCode, Stat stat, byte[] bytes) {
if (resultCode == KeeperException.Code.OK.intValue()) {
// otherwise - node must have dropped or something - we should be getting another event
ChildData<T> data = new ChildData<T>(fullPath, stat, bytes, decode(bytes));
ChildData<T> previousData = currentData.put(fullPath, data);
if (previousData == null || previousData.getStat().getVersion() != stat.getVersion()) {
offerOperation(new EventOperation(this, GroupListener.GroupEvent.CHANGED));
}
}
}
private void mainLoop() {
while (started.get() && !Thread.currentThread().isInterrupted()) {
try {
operations.take().invoke();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (Exception e) {
handleException(e);
}
}
}
private byte[] encode(T state) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
mapper.writeValue(baos, state);
return baos.toByteArray();
} catch (IOException e) {
throw new IllegalStateException("Unable to decode data", e);
}
}
private T decode(byte[] data) {
try {
return mapper.readValue(data, clazz);
} catch (IOException e) {
throw new IllegalStateException("Unable to decode data", e);
}
}
private void offerOperation(Operation operation) {
if (!operations.contains(operation)) {
operations.offer(operation);
}
// operations.remove(operation); // avoids herding for refresh operations
}
public static <T> Map<String, T> members(ObjectMapper mapper, CuratorFramework curator, String path, Class<T> clazz) throws Exception {
Map<String, T> map = new TreeMap<String, T>();
List<String> nodes = curator.getChildren().forPath(path);
for (String node : nodes) {
byte[] data = curator.getData().forPath(path + "/" + node);
T val = mapper.readValue(data, clazz);
map.put(node, val);
}
return map;
}
public String getId() {
return id;
}
void setId(String id) {
this.id = id;
}
/**
* Returns an indication that the sequential, ephemeral node may be registered more than once for this group
*/
public boolean isUnstable() {
return unstable.get();
}
}