org.cometd.oort.OortMap Maven / Gradle / Ivy
/*
* Copyright (c) 2008 the original author or authors.
*
* Licensed 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.cometd.oort;
import java.util.EventListener;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.CopyOnWriteArrayList;
import org.cometd.bayeux.Promise;
import org.cometd.bayeux.server.BayeuxServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A specialized oort object whose entity is a {@link ConcurrentMap}.
* {@link OortMap} specializes {@code OortObject} and allows optimized replication of map entries
* across the cluster: instead of replicating the whole map, that may be contain a lot of entries,
* only entries that are modified are replicated.
* Applications can use {@link #putAndShare(Object, Object, Result)} and {@link #removeAndShare(Object, Result)}
* to broadcast changes related to single entries, as well as {@link #setAndShare(Object, Result)} to
* change the whole map.
* When a single entry is changed, {@link EntryListener}s are notified.
* {@link DeltaListener} converts whole map updates triggered by {@link #setAndShare(Object, Result)}
* into events for {@link EntryListener}s, giving applications a single listener type to implement
* their business logic.
* The type parameter for keys, {@code K}, must be a String to be able to use this class as-is,
* although usage of {@link OortStringMap} is preferred.
* This is due to the fact that a {@code Map} containing an entry {@code {13:"foo"}}
* is serialized in JSON as {@code {"13":"foo"}} because JSON field names must always be strings.
* When deserialized, it is restored as a {@code Map}, which is incompatible
* with the original type parameter for keys.
* To overcome this issue, subclasses may override {@link #serialize(Object)} and
* {@link #deserialize(Object)}.
* Method {@link #serialize(Object)} should convert the entity object to a format that retains
* enough type information for {@link #deserialize(Object)} to convert the JSON-deserialized entity
* object that has the wrong key type to an entity object that has the right key type, like
* {@link OortLongMap} does.
*
* @param the key type
* @param the value type
*/
public abstract class OortMap extends OortContainer> {
private static final String TYPE_FIELD_ENTRY_VALUE = "oort.map.entry";
private static final String ACTION_FIELD_PUT_VALUE = "oort.map.put";
private static final String ACTION_FIELD_PUT_ABSENT_VALUE = "oort.map.put.absent";
private static final String ACTION_FIELD_REMOVE_VALUE = "oort.map.remove";
private static final String KEY_FIELD = "oort.map.key";
private static final String VALUE_FIELD = "oort.map.value";
private final List> listeners = new CopyOnWriteArrayList<>();
private final Logger logger;
protected OortMap(Oort oort, String name, Factory> factory) {
super(oort, name, factory);
this.logger = LoggerFactory.getLogger(Oort.loggerName(getClass(), oort.getURL(), name));
}
public void addEntryListener(EntryListener listener) {
listeners.add(listener);
}
public void removeEntryListener(EntryListener listener) {
listeners.remove(listener);
}
public void removeEntryListeners() {
listeners.clear();
}
/**
* Updates a single entry of the local entity map with the given {@code key} and {@code value},
* and broadcasts the operation to all nodes in the cluster.
* Calling this method triggers notifications {@link EntryListener}s, both on this node and on remote nodes.
* The entry is guaranteed to be put not when this method returns,
* but when the {@link Result} parameter is notified.
*
* @param key the key to associate the value to
* @param value the value associated with the key
* @param callback the callback invoked with the old value,
* or {@code null} if there is no interest in the old value
* @see #putIfAbsentAndShare(Object, Object, Result)
* @see #removeAndShare(Object, Result)
*/
public void putAndShare(K key, V value, Result callback) {
Map entry = new HashMap<>(2);
entry.put(KEY_FIELD, key);
entry.put(VALUE_FIELD, value);
Data data = new Data<>(6, callback);
data.put(Info.OORT_URL_FIELD, getOort().getURL());
data.put(Info.NAME_FIELD, getName());
data.put(Info.OBJECT_FIELD, entry);
data.put(Info.TYPE_FIELD, TYPE_FIELD_ENTRY_VALUE);
data.put(Info.ACTION_FIELD, ACTION_FIELD_PUT_VALUE);
if (logger.isDebugEnabled()) {
logger.debug("Sharing map put {}", data);
}
BayeuxServer bayeuxServer = getOort().getBayeuxServer();
bayeuxServer.getChannel(getChannelName()).publish(getLocalSession(), data, Promise.noop());
}
/**
* Updates a single entry of the local entity map with the given {@code key} and {@code value}
* if it does not exist yet, and broadcasts the operation to all nodes in the cluster.
* Calling this method triggers notifications {@link EntryListener}s, both on this node and on remote nodes,
* only if the key did not exist.
* The entry is guaranteed to be put not when this method returns,
* but when the {@link Result} parameter is notified.
*
* @param key the key to associate the value to
* @param value the value associated with the key
* @param callback the callback invoked with the old value,
* or {@code null} if there is no interest in the old value
* @see #putAndShare(Object, Object, Result)
*/
public void putIfAbsentAndShare(K key, V value, Result callback) {
Map entry = new HashMap<>(2);
entry.put(KEY_FIELD, key);
entry.put(VALUE_FIELD, value);
Data data = new Data<>(6, callback);
data.put(Info.OORT_URL_FIELD, getOort().getURL());
data.put(Info.NAME_FIELD, getName());
data.put(Info.OBJECT_FIELD, entry);
data.put(Info.TYPE_FIELD, TYPE_FIELD_ENTRY_VALUE);
data.put(Info.ACTION_FIELD, ACTION_FIELD_PUT_ABSENT_VALUE);
if (logger.isDebugEnabled()) {
logger.debug("Sharing map putIfAbsent {}", data);
}
BayeuxServer bayeuxServer = getOort().getBayeuxServer();
bayeuxServer.getChannel(getChannelName()).publish(getLocalSession(), data, Promise.noop());
}
/**
* Removes the given {@code key} from the local entity map,
* and broadcasts the operation to all nodes in the cluster.
* Calling this method triggers notifications {@link EntryListener}s, both on this node and on remote nodes.
* The entry is guaranteed to be removed not when this method returns,
* but when the {@link Result} parameter is notified.
*
* @param key the key to remove
* @param callback the callback invoked with the value,
* or {@code null} if there is no interest in the value
* @see #putAndShare(Object, Object, Result)
*/
public void removeAndShare(K key, Result callback) {
Map entry = new HashMap<>(1);
entry.put(KEY_FIELD, key);
Data data = new Data<>(6, callback);
data.put(Info.OORT_URL_FIELD, getOort().getURL());
data.put(Info.NAME_FIELD, getName());
data.put(Info.OBJECT_FIELD, entry);
data.put(Info.TYPE_FIELD, TYPE_FIELD_ENTRY_VALUE);
data.put(Info.ACTION_FIELD, ACTION_FIELD_REMOVE_VALUE);
if (logger.isDebugEnabled()) {
logger.debug("Sharing map remove {}", data);
}
BayeuxServer bayeuxServer = getOort().getBayeuxServer();
bayeuxServer.getChannel(getChannelName()).publish(getLocalSession(), data, Promise.noop());
}
/**
* Returns the value mapped to the given key from the local entity map of this node.
* Differently from {@link #find(Object)}, only the local entity map is scanned.
*
* @param key the key mapped to the value to return
* @return the value mapped to the given key, or
* {@code null} if the local map does not contain the given key
* @see #find(Object)
*/
public V get(K key) {
Info> info = getInfo(getOort().getURL());
if (info == null) {
return null;
}
return info.getObject().get(key);
}
/**
* Returns the first non-null value mapped to the given key from the entity maps of all nodes.
* Differently from {@link #get(Object)}, entity maps of all nodes are scanned.
*
* @param key the key mapped to the value to return
* @return the value mapped to the given key, or
* {@code null} if the maps do not contain the given key
* @see #get(Object)
*/
public V find(K key) {
for (Info> info : this) {
V result = info.getObject().get(key);
if (result != null) {
return result;
}
}
return null;
}
/**
* @param key the key to search
* @return the first {@link Info} whose entity map contains the given key.
*/
public Info> findInfo(K key) {
for (Info> info : this) {
if (info.getObject().get(key) != null) {
return info;
}
}
return null;
}
@Override
protected boolean isItemUpdate(Map data) {
return TYPE_FIELD_ENTRY_VALUE.equals(data.get(Info.TYPE_FIELD));
}
@Override
protected void onItem(Info> info, Map data) {
// Retrieve entry.
@SuppressWarnings("unchecked")
Map object = (Map)data.get(Info.OBJECT_FIELD);
@SuppressWarnings("unchecked")
K key = (K)object.get(KEY_FIELD);
@SuppressWarnings("unchecked")
V value = (V)object.get(VALUE_FIELD);
// Perform the action.
ConcurrentMap map = info.getObject();
String action = (String)data.get(Info.ACTION_FIELD);
V result = switch (action) {
case ACTION_FIELD_PUT_VALUE -> map.put(key, value);
case ACTION_FIELD_PUT_ABSENT_VALUE -> map.putIfAbsent(key, value);
case ACTION_FIELD_REMOVE_VALUE -> map.remove(key);
default -> throw new IllegalArgumentException(action);
};
// Update the version.
info.put(Info.VERSION_FIELD, data.get(Info.VERSION_FIELD));
// Notify.
Entry entry = new Entry<>(key, result, value);
if (logger.isDebugEnabled()) {
logger.debug("{} map {} of {}", info.isLocal() ? "Local" : "Remote", action, entry);
}
switch (action) {
case ACTION_FIELD_PUT_VALUE -> notifyEntryPut(info, entry);
case ACTION_FIELD_PUT_ABSENT_VALUE -> {
if (result == null) {
notifyEntryPut(info, entry);
}
}
case ACTION_FIELD_REMOVE_VALUE -> notifyEntryRemoved(info, entry);
}
if (data instanceof Data) {
((Data)data).setResult(result);
}
}
private void notifyEntryPut(Info> info, Entry entry) {
for (EntryListener listener : listeners) {
try {
listener.onPut(info, entry);
} catch (Throwable x) {
logger.info("Exception while invoking listener " + listener, x);
}
}
}
private void notifyEntryRemoved(Info> info, Entry elements) {
for (EntryListener listener : listeners) {
try {
listener.onRemoved(info, elements);
} catch (Throwable x) {
logger.info("Exception while invoking listener " + listener, x);
}
}
}
/**
* Listener for entry events that update the entity map, either locally or remotely.
*
* @param the key type
* @param the value type
*/
public interface EntryListener extends EventListener {
/**
* Callback method invoked after an entry is put into the entity map.
*
* @param info the {@link Info} that was changed by the put
* @param entry the entry values
*/
public default void onPut(Info> info, Entry entry) {
}
/**
* Callback method invoked after an entry is removed from the entity map.
*
* @param info the {@link Info} that was changed by the remove
* @param entry the entry values
*/
public default void onRemoved(Info> info, Entry entry) {
}
}
/**
* A triple that holds the key, the previous value and the new value, used to notify entry updates:
*
* (key, oldValue, newValue)
*
*
* @param the key type
* @param the value type
*/
public static class Entry {
private final K key;
private final V oldValue;
private final V newValue;
protected Entry(K key, V oldValue, V newValue) {
this.key = key;
this.oldValue = oldValue;
this.newValue = newValue;
}
/**
* @return the key
*/
public K getKey() {
return key;
}
/**
* @return the value before the change, may be null
*/
public V getOldValue() {
return oldValue;
}
/**
* @return the value after the change, may be null
*/
public V getNewValue() {
return newValue;
}
@Override
public String toString() {
return String.format("(%s=%s->%s)", getKey(), getOldValue(), getNewValue());
}
}
/**
* An implementation of {@link Listener} that converts whole map events into {@link EntryListener} events.
* For example, if an entity map:
*
* {
* key0: value0,
* key1: value1,
* key2: value2
* }
*
* is replaced by a map:
*
* {
* key0: value0,
* key1: valueA,
* key3: valueB
* }
*
* then this listener generates two "put" events with the following {@link Entry entries}:
*
* (key1, value1, valueA)
* (key3, null, valueB)
*
* and one "remove" event with the following {@link Entry entry}:
*
* (key2, value2, null)
*
* Note that no event is emitted for {@code key0}; the values for {@code key0} of the two
* maps are tested via {@link Object#equals(Object)} and if they are equal no event is generated.
*
* @param the key type
* @param the value type
*/
public static class DeltaListener implements Listener> {
private final OortMap oortMap;
public DeltaListener(OortMap oortMap) {
this.oortMap = oortMap;
}
@Override
public void onUpdated(Info> oldInfo, Info> newInfo) {
Map oldMap = oldInfo == null ? Map.of() : oldInfo.getObject();
Map newMap = new HashMap<>(newInfo.getObject());
for (Map.Entry oldEntry : oldMap.entrySet()) {
K key = oldEntry.getKey();
V oldValue = oldEntry.getValue();
V newValue = newMap.remove(key);
Entry entry = new Entry<>(key, oldValue, newValue);
if (newValue == null) {
oortMap.notifyEntryRemoved(newInfo, entry);
} else if (!newValue.equals(oldValue)) {
oortMap.notifyEntryPut(newInfo, entry);
}
}
for (Map.Entry newEntry : newMap.entrySet()) {
Entry entry = new Entry<>(newEntry.getKey(), null, newEntry.getValue());
oortMap.notifyEntryPut(newInfo, entry);
}
}
@Override
public void onRemoved(Info> info) {
for (Map.Entry oldEntry : info.getObject().entrySet()) {
Entry entry = new Entry<>(oldEntry.getKey(), oldEntry.getValue(), null);
oortMap.notifyEntryRemoved(info, entry);
}
}
}
}