/**
* 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.
*
* CHANGES:
*
* - Modified by Gerd Behrmann, May 2016.
*/
package dmg.cells.zookeeper;
import com.google.common.base.Preconditions;
import com.google.common.collect.Ordering;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.api.BackgroundCallback;
import org.apache.curator.framework.listen.ListenerContainer;
import org.apache.curator.framework.recipes.cache.ChildData;
import org.apache.curator.framework.recipes.cache.PathChildrenCacheEvent;
import org.apache.curator.framework.recipes.cache.PathChildrenCacheListener;
import org.apache.curator.framework.state.ConnectionState;
import org.apache.curator.framework.state.ConnectionStateListener;
import org.apache.curator.utils.PathUtils;
import org.apache.curator.utils.ThreadUtils;
import org.apache.curator.utils.ZKPaths;
import org.apache.zookeeper.KeeperException;
import org.apache.zookeeper.Watcher;
import org.apache.zookeeper.data.Stat;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.Closeable;
import java.io.IOException;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicReference;
/**
* <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>
* <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>
* <p>Similar to the Curator recipe of the same name, but simpler and with fewer features. Works around
* issues in Curator 2.10 (see CURATOR-326 and CURATOR-328 in Curator JIRA). We should probably move
* back to the upstream version once a fix is released and maybe work with upstream to improve the
* original recipe further (e.g. non-blocking path creation). </p>
*/
public class PathChildrenCache implements Closeable
{
private final Logger log = LoggerFactory.getLogger(getClass());
private final CuratorFramework client;
private final String path;
private final boolean cacheData;
private final boolean dataIsCompressed;
private final ListenerContainer<PathChildrenCacheListener> listeners = new ListenerContainer<>();
private final ConcurrentMap<String, ChildData> currentData = new ConcurrentHashMap<>();
private final AtomicReference<State> state = new AtomicReference<>(State.LATENT);
private final Watcher childrenWatcher = event -> {
try {
refresh(RefreshMode.NORMAL);
} catch (Exception e) {
handleException(e);
}
};
private final Watcher dataWatcher = event -> {
try {
if (event.getType() == Watcher.Event.EventType.NodeDeleted) {
remove(event.getPath());
} else if (event.getType() == Watcher.Event.EventType.NodeDataChanged) {
getDataAndStat(event.getPath());
} else {
log.debug("Data watcher ignored {}", event);
}
} catch (Exception e) {
handleException(e);
}
};
private final ConnectionStateListener connectionStateListener =
(client, newState) -> handleStateChange(newState);
private enum State
{
LATENT,
STARTED,
CLOSED
}
private enum RefreshMode
{
NORMAL,
REBUILD
}
/**
* @param client the client
* @param path path to watch
* @param cacheData if true, node contents are cached in addition to the stat
*/
public PathChildrenCache(CuratorFramework client, String path, boolean cacheData)
{
this(client, path, cacheData, false);
}
/**
* @param client the client
* @param path path to watch
* @param cacheData if true, node contents are cached in addition to the stat
* @param dataIsCompressed if true, data in the path is compressed
*/
public PathChildrenCache(CuratorFramework client, String path, boolean cacheData, boolean dataIsCompressed)
{
this.client = client;
this.path = PathUtils.validatePath(path);
this.cacheData = cacheData;
this.dataIsCompressed = dataIsCompressed;
}
/**
* Start the cache. The cache is not started automatically. You must call this method.
*
* @throws Exception errors
*/
public void start() throws Exception
{
Preconditions.checkState(state.compareAndSet(State.LATENT, State.STARTED), "already started");
client.getConnectionStateListenable().addListener(connectionStateListener);
refresh(RefreshMode.NORMAL);
}
/**
* Close/end the cache
*
* @throws IOException errors
*/
@Override
public void close() throws IOException
{
if (state.compareAndSet(State.STARTED, State.CLOSED)) {
client.getConnectionStateListenable().removeListener(connectionStateListener);
listeners.clear();
client.clearWatcherReferences(childrenWatcher);
client.clearWatcherReferences(dataWatcher);
}
}
/**
* Return the cache listenable
*
* @return listenable
*/
public ListenerContainer<PathChildrenCacheListener> 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()
{
return Ordering.natural().immutableSortedCopy(currentData.values());
}
/**
* 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);
}
void refresh(RefreshMode mode) throws Exception
{
final BackgroundCallback callback = (client, event) -> {
if (PathChildrenCache.this.state.get() != State.CLOSED) {
if (event.getResultCode() == KeeperException.Code.OK.intValue()) {
processChildren(event.getChildren(), mode);
} else if (event.getResultCode() == KeeperException.Code.NONODE.intValue()) {
ensurePathAndThenRefresh(mode);
} else if (event.getResultCode() == KeeperException.Code.CONNECTIONLOSS.intValue() ||
event.getResultCode() == KeeperException.Code.SESSIONEXPIRED.intValue()) {
log.debug("Refresh callback ignored {}", event);
} else {
handleException(KeeperException.create(event.getResultCode()));
}
}
};
client.getChildren().usingWatcher(childrenWatcher).inBackground(callback).forPath(path);
}
void ensurePathAndThenRefresh(RefreshMode mode) throws Exception
{
BackgroundCallback callback =
(client, event) -> {
if (event.getResultCode() == KeeperException.Code.OK.intValue() ||
event.getResultCode() == KeeperException.Code.NONODE.intValue()) {
refresh(mode);
}
};
client.checkExists().creatingParentContainersIfNeeded().inBackground(callback).forPath(ZKPaths.makePath(path, "ignored"));
}
void callListeners(final PathChildrenCacheEvent event)
{
listeners.forEach (
listener -> {
try {
listener.childEvent(client, event);
} catch (Exception e) {
handleException(e);
}
return null;
});
}
void getDataAndStat(String fullPath) throws Exception
{
BackgroundCallback callback = (client, event) -> {
if (event.getResultCode() == KeeperException.Code.OK.intValue()) {
updateCache(fullPath, event.getStat(), cacheData ? event.getData() : null);
}
};
if (dataIsCompressed && cacheData) {
client.getData().decompressed().usingWatcher(dataWatcher).inBackground(callback).forPath(fullPath);
} else {
client.getData().usingWatcher(dataWatcher).inBackground(callback).forPath(fullPath);
}
}
/**
* Default behavior is just to log the exception
*
* @param e the exception
*/
protected void handleException(Throwable e)
{
if (e instanceof RuntimeException) {
log.error("", e);
} else {
log.error(e.getMessage());
}
ThreadUtils.checkInterrupted(e);
}
protected void remove(String fullPath)
{
ChildData data = currentData.remove(fullPath);
if (data != null) {
callListeners(new PathChildrenCacheEvent(PathChildrenCacheEvent.Type.CHILD_REMOVED, data));
}
}
private void handleStateChange(ConnectionState newState)
{
switch ( newState ) {
case SUSPENDED:
callListeners(new PathChildrenCacheEvent(PathChildrenCacheEvent.Type.CONNECTION_SUSPENDED, null));
break;
case LOST:
callListeners(new PathChildrenCacheEvent(PathChildrenCacheEvent.Type.CONNECTION_LOST, null));
break;
case CONNECTED:
case RECONNECTED:
callListeners(new PathChildrenCacheEvent(PathChildrenCacheEvent.Type.CONNECTION_RECONNECTED, null));
try {
refresh(RefreshMode.REBUILD);
} catch (Exception e) {
handleException(e);
}
break;
}
}
private void processChildren(List<String> children, RefreshMode mode) throws Exception
{
/* Although we got watchers on the nodes and these watchers will remove the cached data
* when the node is removed, the watchers can be lost when the ZooKeeper session expires.
*/
Set<String> removedNodes = new HashSet<>(currentData.keySet());
for (String child : children) {
removedNodes.remove(ZKPaths.makePath(path, child));
}
for (String fullPath : removedNodes) {
remove(fullPath);
}
for (String child : children) {
String fullPath = ZKPaths.makePath(path, child);
if (mode == RefreshMode.REBUILD || !currentData.containsKey(fullPath)) {
getDataAndStat(fullPath);
}
}
}
private void updateCache(String fullPath, Stat stat, byte[] bytes)
{
ChildData data = new ChildData(fullPath, stat, bytes);
ChildData previousData = currentData.put(fullPath, data);
if (previousData == null) {
callListeners(new PathChildrenCacheEvent(PathChildrenCacheEvent.Type.CHILD_ADDED, data));
} else if (previousData.getStat().getVersion() != stat.getVersion()) {
callListeners(new PathChildrenCacheEvent(PathChildrenCacheEvent.Type.CHILD_UPDATED, data));
}
}
}