com.crankuptheamps.client.SOWRecoveryPointAdapter Maven / Gradle / Ivy
///////////////////////////////////////////////////////////////////////////
//
// Copyright (c) 2010-2024 60East Technologies Inc., All Rights Reserved.
//
// This computer software is owned by 60East Technologies Inc. and is
// protected by U.S. copyright laws and other laws and by international
// treaties. This computer software is furnished by 60East Technologies
// Inc. pursuant to a written license agreement and may be used, copied,
// transmitted, and stored only in accordance with the terms of such
// license agreement and with the inclusion of the above copyright notice.
// This computer software or any other copies thereof may not be provided
// or otherwise made available to any other person.
//
// U.S. Government Restricted Rights. This computer software: (a) was
// developed at private expense and is in all respects the proprietary
// information of 60East Technologies Inc.; (b) was not developed with
// government funds; (c) is a trade secret of 60East Technologies Inc.
// for all purposes of the Freedom of Information Act; and (d) is a
// commercial item and thus, pursuant to Section 12.212 of the Federal
// Acquisition Regulations (FAR) and DFAR Supplement Section 227.7202,
// Government's use, duplication or disclosure of the computer software
// is subject to the restrictions set forth by 60East Technologies Inc..
//
////////////////////////////////////////////////////////////////////////////
package com.crankuptheamps.client;
import java.beans.ExceptionListener;
import java.lang.Iterable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.crankuptheamps.client.exception.AMPSException;
import com.crankuptheamps.client.exception.DisconnectedException;
import com.crankuptheamps.client.exception.StoreException;
import com.crankuptheamps.client.fields.Field;
import com.crankuptheamps.client.fields.BookmarkField;
/**
*
* An implementation of the recovery point adapter interface that uses an
* AMPS SOW (State of the World) topic as an external store to persist recovery
* point state for bookmark replay subscriptions.
*
*
*
* This class allows you to use an AMPS SOW topic as a backup bookmark store for
* bookmark replay. It serves as a recovery point adapter that serializes and
* deserializes recovery points to and from the SOW topic. To use this class
* effectively, you will need to configure your AMPS server to include a SOW
* topic that matches the expected format used by this adapter.
*
*
*
* Server-side configuration for AMPS SOW topics is required for successful
* integration with this class. Ensure that your AMPS server is properly
* configured to handle SOW topics, including topics matching the expected
* format used for recovery points. See the 'Java Developer Guide' for the
* suggested configuration.
*
*/
public class SOWRecoveryPointAdapter implements RecoveryPointAdapter {
protected String _trackedName;
protected String _topic;
protected String _clientNameField;
protected String _subIdField;
protected String _bookmarkField;
protected Client _client = null;
protected ExceptionListener _exceptionListener = null;
protected Field _serializeField = new Field();
protected long _timeoutMillis = 0;
protected boolean _closeClient = false;
protected boolean _useTimestamp = false;
protected boolean _throwNotListen = false;
protected Command _cmd = new Command(Message.Command.Publish);
protected MessageStream _stream = null;
protected boolean _recovering = true;
protected static final int JSON_START = 11; // {, 7", 2:, 1,
protected static final int JSON_END = 8; // }, 5", 1:, 1,
protected static final int JSON_LEN = JSON_START + JSON_END;
protected static final int SUBID_LEN = 64; // rough typical max
// Allow for a range with 2 ',' 1 ':' and begin/end markers
protected static final int JSON_INIT_LEN = JSON_LEN + SUBID_LEN + 5
+ (4 * BookmarkField.MAX_BOOKMARK_LENGTH);
protected String _initStr = null;
protected int _serializeStart = 0;
protected RecoveryPoint _current = new FixedRecoveryPoint();
protected Field EPOCH_FIELD = new Field("0");
/**
* Regular expression pattern to pull the bookmark string from subscription
* state saved in the AMPS SOW.
*/
protected Pattern _bookmarkPattern;
/**
* Regular expression pattern to pull the subscription id from subscription
* state saved in the AMPS SOW.
*/
protected Pattern _subIdPattern;
/**
* A concurrent hash map of subscription id's (known to this store)
* mapped to the subscription's most-recent-for-recovery value
* that was last persisted to the SOW. By caching the last
* value, we can prevent unneeded writes to the SOW.
*/
protected final ConcurrentHashMap _lastValue = new ConcurrentHashMap();
/**
* A recovery point adapter implementation that uses an AMPS SOW topic
* as an external store to persist recovery point state for bookmark
* replay subscriptions. See full constructor for details on all options.
*
* @see #SOWRecoveryPointAdapter(Client, String, boolean, boolean, boolean,
* long, String, String, String, String)
* @param client The internal AMPS client instance used to write
* bookmark state to the AMPS SOW. This MUST NOT be the
* client whose registered bookmark store is associated
* with this recovery point adapter. In other words, it
* must not be the AMPS client used to place the
* bookmark replay subscriptions whose recovery state
* is being persisted by this adapter.
* @param trackedClientName The unique name of the subscriber AMPS client
* we're tracking bookmark recovery state for. If your
* client names change with each run, this should be a
* unique session name that is stable across
* application restarts. For example, such a name
* could be formed by concatenating an application name
* with a logical functional area (e.g.
* "MyApp_OrderReplay"). This name will be used as a
* key in the SOW for tracking the subscriber client's
* bookmark state. For guidance on choosing stable
* unique AMPS client names or session names for use
* with this adapter, please see our support FAQ
* article entitled Unique Client Naming and AMPS.
* @param closeClient Indicates whether this adapter instance should close
* its internal AMPS client when this adapter is
* closed. Defaults to true. If this flag is true, this
* adapter considers the internal client passed to it
* during construction as being owned by this adapter,
* such that its life-cycle should end when this
* adapter is closed. If false, this adapter will
* consider it the responsibility of the caller to
* close the internal client after this adapter is
* closed, allowing that client to be shared, possibly
* among multiple adapters (keeping in mind potential
* performance impacts of multiple threads publishing
* using the same AMPS client instance).
* @param useTimestamp Indicates if the last updated timestamp of each
* entries should be included in the RecoveryPoint.
* This is similar to useLastModifiedTime in
* {@link LoggedBookmarkStore}.
* @param throwExceptions Indicates whether exceptions should be thrown
* to the caller (usually the bookmark store), or
* should instead be delivered to this adapter's
* registered exception listener callback. This flag
* lets the user decide their error handling strategy
* for recovery state write failures. The default is
* false, meaning the exception is absorbed, and sent
* to the exception listener (if any). A value of true
* means errors in the update() method throw
* exceptions. NOTE: Since this adapter implementation
* publishes to an AMPS SOW and most AMPS publishing
* failures have asynchronous notification, users
* should consider registering a
* {@link FailedWriteHandler} and publish store on the
* internal client passed above.
* @throws StoreException If client name and trackedClientName are the same.
*/
public SOWRecoveryPointAdapter(Client client, String trackedClientName,
boolean closeClient, boolean useTimestamp,
boolean throwExceptions) throws StoreException {
// It delegates to the full constructor by passing in the provided parameters
// along with some default values for the rest.
// The default values are as follows:
// - timeoutMillis: 0L (no timeout)
// - topic: "/ADMIN/bookmark_store"
// - clientNameField: "clientName"
// - subIdField: "subId"
// - bookmarkField: "bookmark"
this(client, trackedClientName, closeClient, useTimestamp,
throwExceptions, 0L, "/ADMIN/bookmark_store", "clientName",
"subId", "bookmark");
}
/**
* A recovery point adapter implementation that uses an AMPS SOW topic
* as an external store to persist recovery point state for bookmark
* replay subscriptions.
*
* @param client The internal AMPS client instance used to write
* bookmark state to the AMPS SOW. This MUST NOT be the
* client whose registered bookmark store is associated
* with this recovery point adapter. In other words, it
* must not be the AMPS client used to place the
* bookmark replay subscriptions whose recovery state
* is being persisted by this adapter.
* @param trackedClientName The unique name of the subscriber AMPS client
* we're tracking bookmark recovery state for. If your
* client names change with each run, this should be a
* unique session name that is stable across
* application restarts. For example such a name could
* be formed by concatenating an application name with
* a logical functional area (e.g.
* "MyApp_OrderReplay"). This name will be used as a
* key in the SOW for tracking the subscriber client's
* bookmark state. For guidance on choosing stable
* unique AMPS client names or session names for use
* with this adapter, please see our support FAQ
* article entitled Unique Client Naming and AMPS.
* @param closeClient Indicates whether this adapter instance should close
* its internal AMPS client when this adapter is
* closed. Defaults to true. If this flag is true, this
* adapter considers the internal client passed to it
* during construction as being owned by this adapter,
* such that its life-cycle should end when this
* adapter is closed. If false, this adapter will
* consider it the responsibility of the caller to
* close the internal client after this adapter is
* closed, allowing that client to be shared, possibly
* among multiple adapters (keeping in mind potential
* performance impacts of multiple threads publishing
* using the same AMPS client instance).
* @param useTimestamp Indicates if the last updated timestamp of each
* entries should be included in the RecoveryPoint.
* This is similar to useLastModifiedTime in
* {@link LoggedBookmarkStore}.
* @param throwExceptions Indicates whether exceptions should be thrown
* to the caller (usually the bookmark store), or
* should instead be delivered to this adapter's
* registered exception listener callback. This flag
* lets the user decide their error handling strategy
* for recovery state write failures. The default is
* false, meaning the exception is absorbed, and sent
* to the exception listener (if any). A value of true
* means errors in the update() method throw
* exceptions. NOTE: Since this adapter implementation
* publishes to an AMPS SOW and most AMPS publishing
* failures have asynchronous notification, users
* should consider registering a
* {@link FailedWriteHandler} and publish store on the
* internal client passed above.
* @param timeoutMillis The number of milliseconds to wait for the initial
* sow query to complete and for publish_flush to
* complete during {@link #close()}. The default value
* is 0, which means no timeout.
* @param topic The AMPS SOW topic configured to persist bookmark
* recovery state for this adapter. Usually defaults to
* "/ADMIN/bookmark_store", but this allows users to
* customize their usage for non-default SOW configs.
* NOTE: The message type of this topic in the
* AMPS config must be "json" unless overriding the
* operation of this adapter in a subclass.
* @param clientNameField The name of the SOW topic field we use for
* persisting the tracked client name or session name.
* Usually just "clientName", but this allows users to
* customize their usage for non-default SOW configs.
* NOTE: This field must be one of two key fields
* configured on the SOW topic.
* @param subIdField The name of the SOW topic field we use for
* persisting the subscription id. Usually just
* "subId", but this allows users to customize their
* usage for non-default SOW configs. NOTE: This
* field must be one of two key fields configured on
* the SOW topic.
* @param bookmarkField The name of the SOW topic field we use for
* persisting the bookmark. Usually just "bookmark",
* but this allows users to customize their usage for
* non-default SOW configs.
* @throws StoreException If client name and trackedClientName are the same.
*/
public SOWRecoveryPointAdapter(Client client, String trackedClientName,
boolean closeClient,
boolean useTimestamp,
boolean throwExceptions,
long timeoutMillis,
String topic,
String clientNameField,
String subIdField,
String bookmarkField) throws StoreException {
if (client.getName().equals(trackedClientName)) {
throw new StoreException("SOWRecoveryPointAdapter must use a different client than the one it tracks");
}
// Assign provided parameters to instance variables
_client = client;
_trackedName = trackedClientName;
_timeoutMillis = timeoutMillis;
_closeClient = closeClient;
_useTimestamp = useTimestamp;
_throwNotListen = throwExceptions;
_topic = topic;
_clientNameField = clientNameField;
_subIdField = subIdField;
_bookmarkField = bookmarkField;
// Set the topic for the internal command object
_cmd.setTopic(_topic);
// Initialize regex patterns for extracting subId and bookmark fields from JSON
_subIdPattern = Pattern.compile("\"" + _subIdField + "\" *: *\"([^\"]+)\"");
_bookmarkPattern = Pattern.compile("\"bookmark\" *: *\"([^\"]*)\"");
}
/**
* Sets the {@link java.beans.ExceptionListener} instance used for
* communicating absorbed exceptions.
*
* @param exceptionListener The exception listener instance to invoke for
* internal exceptions.
*/
public void setExceptionListener(ExceptionListener exceptionListener) {
this._exceptionListener = exceptionListener;
}
/**
* Sends an update to the underlying SOW.
*
* @param recoveryPoint The recovery state to persist in the SOW.
* @throws Exception StoreException wrapper of exception thrown while
* clearing the SOW of recovery information.
*/
public void update(RecoveryPoint recoveryPoint) throws Exception {
try {
// Don't write to the SOW if the current value equals
// the prev value we wrote (i.e. there's been no change)
Field subId = recoveryPoint.getSubId();
BookmarkField currValue = recoveryPoint.getBookmark();
BookmarkField prevValue = _lastValue.get(subId);
if (prevValue == null || !currValue.equals(prevValue)) {
if (!serialize(recoveryPoint))
return;
_cmd.setData(_serializeField.buffer, _serializeField.position,
_serializeField.length);
_client.execute(_cmd);
// Update last value.
if (prevValue == null) {
_lastValue.put(subId.copy(), currValue.copy());
} else {
_lastValue.put(subId, currValue.copy());
}
}
} catch (Exception e) {
if (_throwNotListen) {
throw new StoreException("Sow update exception " + e, e);
} else {
if (this._exceptionListener != null) {
this._exceptionListener
.exceptionThrown(new StoreException("Sow update exception " + e.toString(), e));
}
}
}
}
/**
* Purge all recovery information stored in the SOW.
*
* @throws StoreException Wrapper of exception thrown while clearing the
* SOW of recovery information.
*/
public void purge() throws StoreException {
try {
String filter = String.format("/%s = '%s'", _clientNameField, _trackedName);
_client.sowDelete(_topic, filter, _timeoutMillis);
} catch (AMPSException e) {
throw new StoreException("Error purging recovery state from SOW: " + e, e);
}
}
/**
* Purge all recovery information stored in the SOW.
*
* @param subId The subId of the subscription to remove.
* @throws StoreException Wrapper of exception thrown while clearing the
* SOW of recovery information.
*/
public void purge(Field subId) throws StoreException {
try {
String filter = String.format("/%s = '%s' AND /%s = '%s'",
_clientNameField, _trackedName, _subIdField, subId);
_client.sowDelete(_topic, filter, _timeoutMillis);
} catch (Exception e) {
throw new StoreException("Error purging recovery state from SOW for subId " + subId + ": " + e, e);
}
}
/**
* Close this adapter making sure all updates are at the server and close
* the internal client if set up to do so.
*/
public void close() throws Exception {
// Check if the internal client instance exists
if (_client != null) {
try {
// Flush any pending updates to the server. This ensures that any recovery state
// updates are sent to the server before closing
_client.publishFlush(_timeoutMillis);
} catch (DisconnectedException e) {
// Handle disconnection exceptions gracefully.
} catch (Exception e) {
// Handle other exceptions that may occur during flushing
if (_throwNotListen) {
// If configured to throw exceptions, rethrow the exception
throw new StoreException("SOWRecoveryPointAdapter error flushing to store during close", e);
} else if (_exceptionListener != null) {
// Otherwise, notify the exception listener if it's registered
_exceptionListener.exceptionThrown(
new StoreException("SOWRecoveryPointAdapter error flushing to store during close", e));
}
} finally {
// If configured to close the internal client, do so
if (_closeClient)
_client.close();
}
}
}
/**
* Set up the MessageStream for data recovery.
*/
protected void runQuery() {
try {
// Create a StringBuilder to build a filter for the SOW query
StringBuilder builder = new StringBuilder();
builder.append('/').append(_clientNameField).append("=\"")
.append(_trackedName).append('"');
// Create a new SOW (State of the World) command
Command cmd = new Command("sow");
// Configure the SOW command with the topic and timeout
cmd.setTopic(_topic).setTimeout(_timeoutMillis)
.setFilter(builder.toString());
// Clear the StringBuilder for building options for the SOW command
builder.setLength(0);
// Build the select options for the SOW command, including subId and bookmark
// fields
builder.append("select=[-/,+/").append(_subIdField)
.append(",+/").append(_bookmarkField).append(']');
// If useTimestamp is enabled, add "timestamp" to the select options
if (_useTimestamp) {
builder.append(",timestamp");
// Set the options for the SOW command
cmd.setOptions(builder.toString());
} else {
cmd.setOptions(builder.toString());
}
// Execute the SOW command on the internal AMPS client and get the MessageStream
_stream = _client.execute(cmd);
} catch (Exception e) {
// Handle exceptions that may occur during the SOW query r.
// If _throwNotListen is false and an exception listener is set,
// report the exception to the listener and set _stream to null
if (!_throwNotListen && _exceptionListener != null) {
_exceptionListener.exceptionThrown(new StoreException("Failed to execute SOW query for recovery", e));
_stream = null;
}
}
// Indicate that the recovery process is not active (completed)
_recovering = false;
}
/**
* Implements {@link Iterator#hasNext}.
*/
public boolean hasNext() {
if (_recovering && _stream == null) {
// If recovery is in progress and the stream is null, trigger a SOW query
runQuery();
}
// Check if there is more data available in the stream
return _stream != null && _stream.hasNext();
}
/**
* Implements {@link Iterator#next} for a message stream.
*/
public RecoveryPoint next() {
if (_recovering && _stream == null) {
runQuery();
}
// Get the next message from the stream
Message m = _stream.next();
if (m == null) {
// If there are no more messages, set the stream to null and return null
_stream = null;
return null;
}
int command = m.getCommand();
if (command == Message.Command.GroupBegin) {
// Skip GroupBegin messages and continue to the next message
return next();
}
if (command != Message.Command.SOW) {
// Group_End, Ack, or something wacky
_stream = null;
return null;
}
try {
// Deserialize the SOW message into a RecoveryPoint
if (!deserialize(m)) {
return next();
}
} catch (Exception e) {
return null;
}
// Return the current RecoveryPoint
return _current;
}
/**
* Operation not supported. Implements {@link Iterator#remove} to throw
* UnsupportedOperationException.
*/
public void remove() {
throw new UnsupportedOperationException();
}
/**
* Implements {@link Iterable#iterator} to return this instance.
*/
public Iterator iterator() {
// Return this instance as the iterator
return this;
}
/**
* Serializes a {@link RecoveryPoint} to JSON in the internal buffer for
* publishing to a JSON SOW topic. Used by the
* {@link #update(RecoveryPoint)} method.
*
* This method provides an override point where a recovery point can be
* serialized another message type into the buffer.
*
* @param recoveryPoint The recovery point to serialize.
* @return true upon successful serialization
* @throws Exception If an error occurs during serialization.
*/
protected boolean serialize(RecoveryPoint recoveryPoint)
throws Exception {
BookmarkField bookmark = recoveryPoint.getBookmark();
if (bookmark.equals(EPOCH_FIELD)) {
return false;
}
Field subId = recoveryPoint.getSubId();
int endLength = subId.length + _bookmarkField.length()
+ bookmark.length + JSON_END;
int length = _clientNameField.length() + _trackedName.length()
+ _subIdField.length() + JSON_START + endLength;
if (_serializeField.buffer == null
|| _serializeField.buffer.length < length) {
// Need a new buffer
int newLength = length + (128 - (length % 128));
byte[] buffer = new byte[newLength];
_serializeField.set(buffer, 0, length);
initSerialization();
}
System.arraycopy(subId.buffer, subId.position, _serializeField.buffer,
_serializeStart, subId.length);
int pos = _serializeStart + subId.length;
_serializeField.buffer[pos++] = (byte) '"';
_serializeField.buffer[pos++] = (byte) ',';
_serializeField.buffer[pos++] = (byte) '"';
System.arraycopy(_bookmarkField.getBytes("UTF8"), 0,
_serializeField.buffer, pos, _bookmarkField.length());
pos += _bookmarkField.length();
_serializeField.buffer[pos++] = (byte) '"';
_serializeField.buffer[pos++] = (byte) ':';
_serializeField.buffer[pos++] = (byte) '"';
System.arraycopy(bookmark.buffer, bookmark.position,
_serializeField.buffer, pos, bookmark.length);
pos += bookmark.length;
_serializeField.buffer[pos++] = (byte) '"';
_serializeField.buffer[pos] = (byte) '}';
_serializeField.length = length;
// Return true if serialization is successful
return true;
}
/**
* Deserializes a JSON string into a {@link RecoveryPoint} instance for use
* by {@link #next()} during bookmark store creation/initialization.
*
* This method provides an override point where another message type
* can be deserialized into a {@link RecoveryPoint}. This method is only
* called via recover() during bookmark store creation/initialization by a
* single thread.
*
* @param m The `sow` Message from the server to deserialize.
* @return true if the Message was successfully deserialized into the
* _current RecoveryPoint.
* @throws Exception If an error occurs during deserialization.
*/
protected boolean deserialize(Message m) throws Exception {
int subIdIdx = 0;
int bookmarkIdx = 0;
String data = m.getData();
Matcher subIdMatch = _subIdPattern.matcher(data);
if (!subIdMatch.find()) {
// If the subId field is not found in the SOW record, throw an exception
throw new StoreException("SubId NOT FOUND in SOW"
+ " record during recovery: data = " + data);
}
Matcher bookmarkMatch = _bookmarkPattern.matcher(data);
if (!bookmarkMatch.find()) {
// If the recovery bookmark is not found in the SOW record, throw an exception
throw new StoreException("Recovery bookmark NOT FOUND in SOW"
+ " record during recovery: data = " + data);
}
// Get the subId and the bookmark
String subId = subIdMatch.group(1);
String bookmark = bookmarkMatch.group(1);
if (_useTimestamp) {
String timestamp = m.getTimestamp();
if (timestamp != null && !timestamp.isEmpty()
&& bookmark.charAt(0) != '[' && bookmark.charAt(0) != '(') {
bookmark = bookmark + "," + timestamp;
}
}
int subIdLen = subId.length();
int bookmarkLen = bookmark.length();
int len = subIdLen + bookmarkLen;
if (_serializeField.buffer == null
|| _serializeField.buffer.length < len) {
// If the serialization buffer is not large enough, create a new buffer
byte[] buffer = new byte[len];
_serializeField.set(buffer, 0, len);
}
// Copy the subId and bookmark data into the serialization buffer
System.arraycopy(subId.getBytes("UTF8"), 0, _serializeField.buffer, 0,
subIdLen);
System.arraycopy(bookmark.getBytes("UTF8"), 0, _serializeField.buffer,
subIdLen, bookmarkLen);
_current.getSubId().set(_serializeField.buffer, 0, subIdLen);
_current.getBookmark().set(_serializeField.buffer, subIdLen,
bookmarkLen);
return true;
}
/**
* Initializes the serialization buffer and an initial string for serialization
* purposes.
* This method prepares the serialization buffer and an initial JSON-like string
* for later serialization tasks.
* It handles buffer initialization, ensures sufficient space, and populates the
* initial string if necessary.
*
* @throws Exception if there is an issue during initialization.
*/
protected void initSerialization() throws Exception {
// Check if the buffer for serialization is null or too small
if (_serializeField.buffer == null
|| _serializeField.buffer.length < JSON_INIT_LEN) {
// If it is null or too small, create a new buffer with a specific size
byte[] buffer = new byte[JSON_INIT_LEN];
_serializeField.set(buffer, 0, JSON_INIT_LEN);
}
if (_initStr == null) {
// If the initial string is null, create a StringBuilder to build an initial
// JSON-like string
StringBuilder builder = new StringBuilder(JSON_INIT_LEN);
builder.append("{\"").append(_clientNameField).append("\":\"")
.append(_trackedName).append("\",\"")
.append(_subIdField).append("\":\"");
_initStr = builder.toString();
// Set the _serializeStart to the length of the initial string
_serializeStart = _initStr.length();
}
// Copy the initial string into the serialization buffer
System.arraycopy(_initStr.getBytes("UTF8"), 0,
_serializeField.buffer, 0, _initStr.length());
}
}