org.phoebus.applications.alarm.client.AlarmClient Maven / Gradle / Ivy
/*******************************************************************************
* Copyright (c) 2018-2022 Oak Ridge National Laboratory.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*******************************************************************************/
package org.phoebus.applications.alarm.client;
import org.apache.kafka.clients.consumer.Consumer;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.producer.Producer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.record.TimestampType;
import org.phoebus.applications.alarm.AlarmSystem;
import org.phoebus.applications.alarm.model.AlarmTreeItem;
import org.phoebus.applications.alarm.model.AlarmTreePath;
import org.phoebus.applications.alarm.model.json.JsonModelReader;
import org.phoebus.applications.alarm.model.json.JsonModelWriter;
import org.phoebus.applications.alarm.model.json.JsonTags;
import org.phoebus.util.time.TimestampFormats;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
import static org.phoebus.applications.alarm.AlarmSystem.logger;
/**
* Alarm client model
*
* Given an alarm configuration name like "Accelerator",
* subscribes to the "Accelerator" topic for configuration updates
* and the "AcceleratorState" topic for alarm state updates.
*
*
Updates from either topic are merged into an in-memory model
* of the complete alarm information,
* updating listeners with all changes.
*
* @author Kay Kasemir
*/
@SuppressWarnings("nls")
public class AlarmClient {
/**
* Kafka topics for config/status and commands
*/
private final String config_topic, command_topic;
/**
* Listeners to this client
*/
private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>();
/**
* Alarm tree root
*/
private final AlarmClientNode root;
/**
* Timeout in seconds waiting for response from Kafka when sending producer messages.
*/
private static final int KAFKA_CLIENT_TIMEOUT = 10;
/**
* Alarm tree Paths that have been deleted.
*
* Used to distinguish between paths that are not in the alarm tree
* because we have never seen a config or status update for them,
* and entries that have been deleted, so further state updates
* should be ignored until the item is again added (config message).
*/
private final Set deleted_paths = ConcurrentHashMap.newKeySet();
/**
* Flag for message handling thread to run or exit
*/
private final AtomicBoolean running = new AtomicBoolean(true);
/**
* Currently in maintenance mode?
*/
private final AtomicBoolean maintenance_mode = new AtomicBoolean(false);
/**
* Currently in silent mode?
*/
private final AtomicBoolean disable_notify = new AtomicBoolean(false);
/**
* Kafka consumer
*/
private final Consumer consumer;
/**
* Kafka producer
*/
private final Producer producer;
/**
* Message handling thread
*/
private final Thread thread;
/**
* Time of last state update (ms),
* used to determine timeout
*/
private long last_state_update = 0;
/**
* Timeout, not seen any messages from server?
*/
private volatile boolean has_timed_out = false;
/**
* @param server Kafka Server host:port
* @param config_name Name of alarm tree root
* @param kafka_properties_file File to load additional kafka properties from
*/
public AlarmClient(final String server, final String config_name, final String kafka_properties_file) {
Objects.requireNonNull(server);
Objects.requireNonNull(config_name);
config_topic = config_name;
command_topic = config_name + AlarmSystem.COMMAND_TOPIC_SUFFIX;
root = new AlarmClientNode(null, config_name);
final List topics = List.of(config_topic);
consumer = KafkaHelper.connectConsumer(server, topics, topics, kafka_properties_file);
producer = KafkaHelper.connectProducer(server, kafka_properties_file);
thread = new Thread(this::run, "AlarmClientModel " + config_name);
thread.setDaemon(true);
}
/**
* @param listener Listener to add
*/
public void addListener(final AlarmClientListener listener) {
listeners.add(listener);
}
/**
* @param listener Listener to remove
*/
public void removeListener(final AlarmClientListener listener) {
if (!listeners.remove(listener))
throw new IllegalStateException("Unknown listener");
}
/**
* Start client
*
* @see #shutdown()
*/
public void start() {
thread.start();
}
/**
* @return true
if start()
had been called
*/
public boolean isRunning() {
return thread.isAlive();
}
/**
* @return Root of alarm configuration
*/
public AlarmClientNode getRoot() {
return root;
}
/**
* @return Is alarm server in maintenance mode?
*/
public boolean isMaintenanceMode() {
return maintenance_mode.get();
}
/**
* @return Is alarm server in disable notify mode?
*/
public boolean isDisableNotify() {
return disable_notify.get();
}
/**
* Client code must not call this on the UI thread as it may block up to {@link #KAFKA_CLIENT_TIMEOUT} seconds.
*
* @param maintenance Select maintenance mode?
* @throws Exception if Kafka interaction fails for any reason.
*/
public void setMode(final boolean maintenance) throws Exception {
final String cmd = maintenance ? JsonTags.MAINTENANCE : JsonTags.NORMAL;
try {
final String json = new String(JsonModelWriter.commandToBytes(cmd));
final ProducerRecord record = new ProducerRecord<>(command_topic, AlarmSystem.COMMAND_PREFIX + root.getPathName(), json);
producer.send(record).get(KAFKA_CLIENT_TIMEOUT, TimeUnit.SECONDS);
} catch (final Exception ex) {
logger.log(Level.WARNING, "Cannot set mode for " + root + " to " + cmd, ex);
throw ex;
}
}
/**
* Client must not call this on the UI thread as it may block up to {@link #KAFKA_CLIENT_TIMEOUT} seconds.
*
* @param disable_notify Select notify disable ?
* @throws Exception if Kafka interaction fails for any reason.
*/
public void setNotify(final boolean disable_notify) throws Exception {
final String cmd = disable_notify ? JsonTags.DISABLE_NOTIFY : JsonTags.ENABLE_NOTIFY;
try {
final String json = new String(JsonModelWriter.commandToBytes(cmd));
final ProducerRecord record = new ProducerRecord<>(command_topic, AlarmSystem.COMMAND_PREFIX + root.getPathName(), json);
producer.send(record).get(KAFKA_CLIENT_TIMEOUT, TimeUnit.SECONDS);
} catch (final Exception ex) {
logger.log(Level.WARNING, "Cannot set mode for " + root + " to " + cmd, ex);
throw ex;
}
}
/**
* Background thread loop that checks for alarm tree updates
*/
private void run() {
// Send an initial "no server" notification,
// to be cleared once we receive data from server.
checkServerState();
try {
while (running.get()) {
checkUpdates();
checkServerState();
}
} catch (final Throwable ex) {
if (running.get())
logger.log(Level.SEVERE, "Alarm client model error", ex);
// else: Intended shutdown
} finally {
consumer.close();
producer.close();
}
}
/**
* Time spent in checkUpdates() waiting for, well, updates
*/
private static final Duration POLL_PERIOD = Duration.ofMillis(100);
/**
* Perform one check for updates
*/
private void checkUpdates() {
// Check for messages, with timeout.
// TODO Because of Kafka bug, this will hang if Kafka isn't running.
// Fixed according to https://issues.apache.org/jira/browse/KAFKA-1894 ,
// but update to kafka-client 1.1.1 (latest in July 2018) makes no difference.
final ConsumerRecords records = consumer.poll(POLL_PERIOD);
for (final ConsumerRecord record : records)
handleUpdate(record);
}
/**
* Handle one received update
*
* @param record Kafka record
*/
private void handleUpdate(final ConsumerRecord record) {
final int sep = record.key().indexOf(':');
if (sep < 0) {
logger.log(Level.WARNING, "Invalid key, expecting type:path, got " + record.key());
return;
}
final String type = record.key().substring(0, sep + 1);
final String path = record.key().substring(sep + 1);
final long timestamp = record.timestamp();
final String node_config = record.value();
if (record.timestampType() != TimestampType.CREATE_TIME)
logger.log(Level.WARNING, "Expect updates with CreateTime, got " + record.timestampType() + ": " + record.timestamp() + " " + path + " = " + node_config);
logger.log(Level.FINE, () ->
record.topic() + " @ " +
TimestampFormats.MILLI_FORMAT.format(Instant.ofEpochMilli(timestamp)) + " " +
type + path + " = " + node_config);
try {
// Only update listeners if the node changed
AlarmTreeItem> changed_node = null;
final Object json = node_config == null ? null : JsonModelReader.parseJsonText(node_config);
if (type.equals(AlarmSystem.CONFIG_PREFIX)) {
if (json == null) { // No config -> Delete node
final AlarmTreeItem> node = deleteNode(path);
// If this was a known node, notify listeners
if (node != null) {
logger.log(Level.FINE, () -> "Delete " + path);
for (final AlarmClientListener listener : listeners)
listener.itemRemoved(node);
}
} else { // Configuration update
if (JsonModelReader.isStateUpdate(json))
logger.log(Level.WARNING, "Got config update with state content: " + record.key() + " " + node_config);
else {
AlarmTreeItem> node = findNode(path);
// New node? Will need to send update. Otherwise update when there's a change
if (node == null)
changed_node = node = findOrCreateNode(path, JsonModelReader.isLeafConfigOrState(json));
if (JsonModelReader.updateAlarmItemConfig(node, json))
changed_node = node;
}
}
} else if (type.equals(AlarmSystem.STATE_PREFIX)) { // State update
if (json == null) { // State update for deleted node, ignore
logger.log(Level.FINE, () -> "Got state update for deleted node: " + record.key() + " " + node_config);
return;
} else if (!JsonModelReader.isStateUpdate(json)) {
logger.log(Level.WARNING, "Got state update with config content: " + record.key() + " " + node_config);
return;
} else if (deleted_paths.contains(path)) {
// It it _deleted_??
logger.log(Level.FINE, () -> "Ignoring state for deleted item: " + record.key() + " " + node_config);
return;
} else {
AlarmTreeItem> node = findNode(path);
// New node? Create, and remember to notify
if (node == null)
changed_node = node = findOrCreateNode(path, JsonModelReader.isLeafConfigOrState(json));
final boolean maint = JsonModelReader.isMaintenanceMode(json);
if (maintenance_mode.getAndSet(maint) != maint)
for (final AlarmClientListener listener : listeners)
listener.serverModeChanged(maint);
final boolean disnot = JsonModelReader.isDisableNotify(json);
if (disable_notify.getAndSet(disnot) != disnot)
for (final AlarmClientListener listener : listeners)
listener.serverDisableNotifyChanged(disnot);
if (JsonModelReader.updateAlarmState(node, json))
changed_node = node;
last_state_update = System.currentTimeMillis();
}
}
// else: Neither config nor state update; ignore.
// If there were changes, notify listeners
if (changed_node != null) {
logger.log(Level.FINE, "Update " + path + " to " + changed_node.getState());
for (final AlarmClientListener listener : listeners)
listener.itemUpdated(changed_node);
}
} catch (final Exception ex) {
logger.log(Level.WARNING,
"Alarm config update error for path " + path +
", config " + node_config, ex);
}
}
/**
* Find existing node
*
* @param path Path to node
* @return Node, null
if model does not contain the node
* @throws Exception on error
*/
private AlarmTreeItem> findNode(final String path) throws Exception {
final String[] path_elements = AlarmTreePath.splitPath(path);
// Start of path must match the alarm tree root
if (path_elements.length < 1 ||
!root.getName().equals(path_elements[0]))
throw new Exception("Invalid path for alarm configuration " + root.getName() + ": " + path);
// Walk down the path
AlarmTreeItem> node = root;
for (int i = 1; i < path_elements.length; ++i) {
final String name = path_elements[i];
node = node.getChild(name);
if (node == null)
return null;
}
return node;
}
/**
* Delete node
*
* It's OK to try delete an unknown node:
* The node might have once existed, but was then deleted.
* The last entry in the configuration database is then the deletion hint.
* A new model that reads this node-to-delete information
* thus never knew the node.
*
* @param path Path to node to delete
* @return Node that was removed, or null
if model never knew that node
* @throws Exception on error
*/
private AlarmTreeItem> deleteNode(final String path) throws Exception {
// Mark path as deleted so we ignore state updates
deleted_paths.add(path);
final AlarmTreeItem> node = findNode(path);
if (node == null)
return null;
// Node is known: Detach it
node.detachFromParent();
return node;
}
/**
* Find an existing alarm tree item or create a new one
*
*
Informs listener about created nodes,
* if necessary one notification for each created node along the path.
*
* @param path Alarm tree path
* @param is_leaf Is this the path to a leaf?
* @return {@link AlarmTreeItem}
* @throws Exception on error
*/
private AlarmTreeItem> findOrCreateNode(final String path, final boolean is_leaf) throws Exception {
// In case it was previously deleted:
deleted_paths.remove(path);
final String[] path_elements = AlarmTreePath.splitPath(path);
// Start of path must match the alarm tree root
if (path_elements.length < 1 ||
!root.getName().equals(path_elements[0]))
throw new Exception("Invalid path for alarm configuration " + root.getName() + ": " + path);
// Walk down the path
AlarmClientNode parent = root;
for (int i = 1; i < path_elements.length; ++i) {
final String name = path_elements[i];
final boolean last = i == path_elements.length - 1;
AlarmTreeItem> node = parent.getChild(name);
// Create missing nodes
if (node == null) { // Done when creating leaf
if (last && is_leaf) {
node = new AlarmClientLeaf(parent.getPathName(), name);
node.addToParent(parent);
logger.log(Level.FINE, "Create " + path);
for (final AlarmClientListener listener : listeners)
listener.itemAdded(node);
return node;
} else {
node = new AlarmClientNode(parent.getPathName(), name);
node.addToParent(parent);
for (final AlarmClientListener listener : listeners)
listener.itemAdded(node);
}
}
// Reached desired node?
if (last)
return node;
// Found or created intermediate node; continue walking down the path
if (!(node instanceof AlarmClientNode))
throw new Exception("Expected intermediate node, found " +
node.getClass().getSimpleName() + " " + node.getName() +
" while traversing " + path);
parent = (AlarmClientNode) node;
}
// If path_elements.length == 1, loop never ran. Return root == parent
return parent;
}
/**
* Add a component to the alarm tree
*
* @param path_name to parent Root or parent component under which to add the component
* @param new_name Name of the new component
*/
public void addComponent(final String path_name, final String new_name) {
try {
sendNewItemInfo(path_name, new_name, new AlarmClientNode(null, new_name));
} catch (final Exception ex) {
logger.log(Level.WARNING, "Cannot add component " + new_name + " to " + path_name, ex);
}
}
/**
* Add a component to the alarm tree
*
* @param path_name to parent Root or parent component under which to add the component
* @param new_name Name of the new component
*/
public void addPV(final String path_name, final String new_name) {
try {
sendNewItemInfo(path_name, new_name, new AlarmClientLeaf(null, new_name));
} catch (final Exception ex) {
logger.log(Level.WARNING, "Cannot add pv " + new_name + " to " + path_name, ex);
}
}
private void sendNewItemInfo(String path_name, final String new_name, final AlarmTreeItem> content) throws Exception {
// Send message about new component.
// All clients, including this one, will receive and then add the new component.
final String new_path = AlarmTreePath.makePath(path_name, new_name);
sendItemConfigurationUpdate(new_path, content);
}
/**
* Client code must not call this on the UI thread as it may block up to {@link #KAFKA_CLIENT_TIMEOUT} seconds.
* Send item configuration.
*
All clients, including this one, will update when they receive the message
*
* @param path Path to the item
* @param config A prototype item (path is ignored) that holds the new configuration
* @throws Exception on error
*/
public void sendItemConfigurationUpdate(final String path, final AlarmTreeItem> config) throws Exception {
final String json = new String(JsonModelWriter.toJsonBytes(config));
final ProducerRecord record = new ProducerRecord<>(config_topic, AlarmSystem.CONFIG_PREFIX + path, json);
producer.send(record).get(KAFKA_CLIENT_TIMEOUT, TimeUnit.SECONDS);
}
/**
* Client must should not call this on the UI thread as it may block up to 2x{@link #KAFKA_CLIENT_TIMEOUT} seconds.
* Remove a component (and sub-items) from alarm tree
*
* @param item Item to remove
* @throws Exception on error
*/
public void removeComponent(final AlarmTreeItem> item) throws Exception {
try {
// Depth first deletion of all child nodes.
final List> children = item.getChildren();
for (final AlarmTreeItem> child : children)
removeComponent(child);
// Send message about item to remove
// All clients, including this one, will receive and then remove the item.
// Remove from configuration
// Create and send a message identifying who is deleting the node.
// The id message must arrive before the tombstone.
final String json = new String(JsonModelWriter.deleteMessageToBytes());
final ProducerRecord id = new ProducerRecord<>(config_topic, AlarmSystem.CONFIG_PREFIX + item.getPathName(), json);
producer.send(id).get(KAFKA_CLIENT_TIMEOUT, TimeUnit.SECONDS);
final ProducerRecord tombstone = new ProducerRecord<>(config_topic, AlarmSystem.CONFIG_PREFIX + item.getPathName(), null);
producer.send(tombstone).get(KAFKA_CLIENT_TIMEOUT, TimeUnit.SECONDS);
} catch (Exception ex) {
throw new Exception("Error deleting " + item.getPathName(), ex);
}
}
/**
* Client must should not call this on the UI thread as it may block up to {@link #KAFKA_CLIENT_TIMEOUT} seconds.
*
* @param item Item for which to acknowledge alarm
* @param acknowledge true
to acknowledge, else un-acknowledge
*/
public void acknowledge(final AlarmTreeItem> item, final boolean acknowledge) throws Exception {
try {
final String cmd = acknowledge ? "acknowledge" : "unacknowledge";
final String json = new String(JsonModelWriter.commandToBytes(cmd));
final ProducerRecord record = new ProducerRecord<>(command_topic, AlarmSystem.COMMAND_PREFIX + item.getPathName(), json);
producer.send(record).get(KAFKA_CLIENT_TIMEOUT, TimeUnit.SECONDS);
} catch (final Exception ex) {
logger.log(Level.WARNING, "Cannot acknowledge component " + item, ex);
throw ex;
}
}
/**
* @return true
if connected to server, else updates have timed out
*/
public boolean isServerAlive() {
return !has_timed_out;
}
/**
* Check if there have been any messages from server
*/
private void checkServerState() {
final long now = System.currentTimeMillis();
if (now - last_state_update > AlarmSystem.idle_timeout_ms * 3) {
if (!has_timed_out) {
has_timed_out = true;
for (final AlarmClientListener listener : listeners)
listener.serverStateChanged(false);
}
} else if (has_timed_out) {
has_timed_out = false;
for (final AlarmClientListener listener : listeners)
listener.serverStateChanged(true);
}
}
/**
* Stop client
*/
public void shutdown() {
running.set(false);
consumer.wakeup();
try {
thread.join(2000);
} catch (final InterruptedException ex) {
logger.log(Level.WARNING, thread.getName() + " thread doesn't shut down", ex);
}
logger.log(Level.INFO, () -> thread.getName() + " shut down");
}
}