/*
* Copyright (C) 2012, 2016 higherfrequencytrading.com
* Copyright (C) 2016 Roman Leventov
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package net.openhft.chronicle.map.utility;
import net.openhft.affinity.AffinitySupport;
import net.openhft.chronicle.algo.locks.AcquisitionStrategies;
import net.openhft.chronicle.algo.locks.ReadWriteLockingStrategy;
import net.openhft.chronicle.algo.locks.TryAcquireOperations;
import net.openhft.chronicle.algo.locks.VanillaReadWriteWithWaitsLockingStrategy;
import net.openhft.chronicle.bytes.Byteable;
import net.openhft.chronicle.core.Jvm;
import net.openhft.chronicle.map.ChronicleMap;
import net.openhft.chronicle.map.ChronicleMapBuilder;
import net.openhft.chronicle.values.Array;
import net.openhft.chronicle.values.Group;
import net.openhft.chronicle.values.Values;
import java.io.File;
import java.io.IOException;
import java.util.Date;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import static net.openhft.chronicle.algo.bytes.Access.checkedBytesStoreAccess;
/**
* ProcessInstanceLimiter limits the number of JVM processes of a particular
* type that can be started on a particular machine. It does this be using a
* shared map (using ChronicleMap) to maintain shared data across any processes
* which use a ProcessInstanceLimiter, and checking on startup and regularly
* after whether it is allowed to run.
* <p/>
* <p>Typically, you need to specify two things to create an instance of
* ProcessInstanceLimiter: a path to a file that will hold the shared map; and a
* callback object (an instance implementing ProcessInstanceLimiter.Callback) to
* handle the various possible callback messages that the ProcessInstanceLimiter
* can generate.
* <p/>
* <p>Once you have a ProcessInstanceLimiter instance, you specify a type of
* process (any string) which will be limited to up to N processes running at
* the same time by calling the setMaxNumberOfProcessesOfType() method. Finally
* you tell the instance you are starting your process of type X by calling
* startingProcessOfType(X). This last is deliberately not done automatically as
* you may wish for one type of process to define limitations on other types of
* processes.
* <p/>
* <p>The are some convenience methods which allow you to quickly specify a limit
* without consideration of the above. For example, if during your application
* startup you call ProcessInstanceLimiter.limitTo(2), then you need not call
* anything else and you have limited your application to running at most 2 JVM
* instances of your application. Under the covers, this call is identical to
* the sequence:
* <p/>
* <p>ProcessInstanceLimiter limiter = new ProcessInstanceLimiter();
* limiter.setMaxNumberOfProcessesOfType(processType,numProcesses);
* limiter.startingProcessOfType(processType);
* <p/>
* <p>This:
* 1. Creates a shared file called ProcessInstanceLimiter_DEFAULT_SHARED_MAP_ in
* the temp directory to hold an instance of ChronicleMap
* 2. Creates an instance of ProcessInstanceLimiter.DefaultCallback to handle
* all callbacks in a reasonable way - all callbacks will emit a message on stdout
* (using System.out) and those that indicate a conflict will exit the process
* (using System.exit)
* 3. Call setMaxNumberOfProcessesOfType("_DEFAULT_",2) to specify that at most only
* 2 processes designated as type _DEFAULT_ will be allowed to run
* 4. Calls startingProcessOfType("_DEFAULT_") to indicate that this process is an
* instance of a _DEFAULT_ type process, and so should be limited appropriately
*/
public class ProcessInstanceLimiter implements Runnable {
private static final long DEFAULT_TIME_UPDATE_INTERVAL_MS = 100L;
private static final String DEFAULT_SHARED_MAP_NAME = "ProcessInstanceLimiter_DEFAULT_SHARED_MAP_";
private static final String DEFAULT_SHARED_MAP_DIRECTORY = System.getProperty("java.io.tmpdir");
private static final String DEFAULT_PROCESS_NAME = "_DEFAULT_";
static {
AffinitySupport.setThreadId();
}
private final String sharedMapPath;
private final ChronicleMap<String, Data> theSharedMap;
private final Callback callback;
private final Map<String, Integer> localUpdates = new ConcurrentHashMap<String, Integer>();
private final Map<String, String> processTypeToStartTimeType = new ConcurrentHashMap<String, String>();
private long timeUpdateInterval = DEFAULT_TIME_UPDATE_INTERVAL_MS;
private long startTime;
private long[] lastStartTimes;
private Map<String, Data> timedata = new ConcurrentHashMap<String, Data>();
private Map<String, Data> starttimedata = new ConcurrentHashMap<String, Data>();
/**
* Create a ProcessInstanceLimiter instance with a default callback, an
* instance of "DefaultCallback", and using the default shared file named
* ProcessInstanceLimiter_DEFAULT_SHARED_MAP_ in the temp directory.
*
* @throws IOException - if the default shared file cannot be created
*/
public ProcessInstanceLimiter() throws IOException {
this(new DefaultCallback());
((DefaultCallback) this.getCallback()).setLimiter(this);
}
/**
* Create a ProcessInstanceLimiter instance using the default shared file
* named ProcessInstanceLimiter_DEFAULT_SHARED_MAP_ in the temp directory.
*
* @param callback - An instance of the Callback interface, which will receive
* callbacks
* @throws IOException - if the default shared file cannot be created
*/
public ProcessInstanceLimiter(Callback callback) throws IOException {
this(DEFAULT_SHARED_MAP_DIRECTORY + System.getProperty("file.separator") + DEFAULT_SHARED_MAP_NAME, callback);
}
/**
* Create a ProcessInstanceLimiter instance using the default shared file
* named ProcessInstanceLimiter_DEFAULT_SHARED_MAP_ in the tmp directory.
*
* @param sharedMapPath - The path to a file which will be used to store the shared
* map (the file need not pre-exist)
* @param callback - An instance of the Callback interface, which will receive
* callbacks
* @throws IOException - if the default shared file cannot be created
*/
public ProcessInstanceLimiter(String sharedMapPath, Callback callback) throws IOException {
this.sharedMapPath = sharedMapPath;
this.callback = callback;
ChronicleMapBuilder<String, Data> builder =
ChronicleMapBuilder.of(String.class, Data.class);
builder.entries(1000);
builder.averageKeySize((DEFAULT_PROCESS_NAME + "#").length());
this.theSharedMap = builder.createPersistedTo(new File(sharedMapPath));
Thread t = new Thread(this, "ProcessInstanceLimiter updater");
t.setDaemon(true);
t.start();
}
public static void main(String[] args) throws IOException, InterruptedException {
ProcessInstanceLimiter.limitTo(2);
Jvm.pause(60L * 1000L);
}
/**
* Convenience method.
* <p/>
* <p>Create a ProcessInstanceLimiter instance which is limited to one OS
* process instance of the DEFAULT type. This will enforce that any JVM on
* the same box which runs the code
* "ProcessInstanceLimiter.limitToOneProcess()" will only have at most one
* JVM instance running at a time.
*
* @return - the ProcessInstanceLimiter instance
* @throws IOException - if the default shared file cannot be created
*/
public static ProcessInstanceLimiter limitToOneProcess() throws IOException {
return limitTo(1);
}
/**
* Convenience method.
* <p/>
* <p>Create a ProcessInstanceLimiter instance which is limited to
* "numProcesses" OS process instances of the DEFAULT type. This will
* enforce that any JVM on the same box which runs the code
* "ProcessInstanceLimiter.limitTo(numProcesses)" will only have at most
* numProcesses JVM instances running at a time. All the JVMs must use the
* same "numProcesses" value or the process will immediately exit with a
* configuration error.
*
* @param numProcesses - the number of JVM processes that can run at any one time
* @return - the ProcessInstanceLimiter instance
* @throws IOException - if the default shared file cannot be created
*/
public static ProcessInstanceLimiter limitTo(int numProcesses) throws IOException {
return limitTo(numProcesses, DEFAULT_PROCESS_NAME);
}
/**
* Convenience method.
* <p/>
* <p>Create a ProcessInstanceLimiter instance which is limited to
* "numProcesses" OS process instances of the "processType" type. This will
* enforce that any JVM on the same box which runs the code
* "ProcessInstanceLimiter.limitTo(numProcesses, processType)" will only
* have at most numProcesses JVM instances running at a time. All the JVMs
* must use the same "numProcesses" value for a "processType" or the process
* will immediately exit with a configuration error.
*
* @param numProcesses - the number of JVM processes that can run at any one time
* @param processType - any string, specifies the type of process that is limited to
* numProcesses processes
* @return - the ProcessInstanceLimiter instance
* @throws IOException - if the default shared file cannot be created
*/
public static ProcessInstanceLimiter limitTo(int numProcesses, String processType) throws IOException {
ProcessInstanceLimiter limiter = new ProcessInstanceLimiter();
limiter.setMaxNumberOfProcessesOfType(processType, numProcesses);
limiter.startingProcessOfType(processType);
return limiter;
}
/**
* Sleeps the thread for the specified number of milliseconds, ignoring
* interruptions.
*
* @param pause - time in milliseconds to sleep
*/
public static void pause(long pause) {
long start = System.currentTimeMillis();
long elapsedTime;
while ((elapsedTime = System.currentTimeMillis() - start) < pause) {
Thread.interrupted();// clear interruptions.
Jvm.pause(pause - elapsedTime);
}
}
/**
* The path to the shared file which stored the shared map.
*/
public String getSharedMapPath() {
return sharedMapPath;
}
/**
* The instance of the Callback interface held by the instance, which will
* receive callbacks
*/
public Callback getCallback() {
return this.callback;
}
/**
* Returns the MaxNumberOfProcesses allowed for processes of type
* "processType", as specified in the shared map. If that type hasn't been
* set, then this returns -1 (which is an invalid value, as it must be a
* positive value)
*/
public int getMaxNumberOfProcessesAllowedFor(String processType) {
Data data = this.starttimedata.get(processType);
if (data == null) {
return -1;
} else {
return data.getMaxNumberOfProcessesAllowed();
}
}
/**
* The interval between updates to the shared map timestamps - i.e. this is
* the interval between notifications of other processes starting
*/
public long getTimeUpdateInterval() {
return timeUpdateInterval;
}
/**
* Set the interval between updates to the shared map timestamps - i.e. this
* is the interval between notifications of other processes starting
*/
public void setTimeUpdateInterval(long timeUpdateInterval) {
this.timeUpdateInterval = timeUpdateInterval;
}
/**
* run() method for the ProcessInstanceLimiter which it starts in a thread
* called "ProcessInstanceLimiter updater"
*/
public void run() {
//every timeUpdateInterval milliseconds, update the time
while (true) {
try {
pause(timeUpdateInterval);
String processType;
Set<Entry<String, Integer>> entrySet = this.localUpdates.entrySet();
for (Entry<String, Integer> entry : entrySet) {
processType = entry.getKey();
int index = entry.getValue().intValue();
Data data = this.timedata.get(processType);
if (data == null) {
entrySet.remove(entry);
} else {
if (!lock(data, 100000)) {
entrySet.remove(entry);
this.callback.lockConflictDetected(processType, index);
} else {
try {
if (!updateTheSharedMap(processType, index, data)) {
entrySet.remove(entry);
}
} finally {
//and release the lock
unlock(data);
}
}
}
}
} catch (Exception e) {
// TODO
e.printStackTrace();
}
}
}
/**
* Call this near the start of the process - if the process can acquire a
* slot, it will callback thisProcessOfTypeHasStartedAtSlot(), otherwise one
* of the other callback interface methods will be called.
*
* @param processType
*/
public void startingProcessOfType(String processType) {
Data data = this.timedata.get(processType);
if (data == null) {
this.callback.noDefinitionForProcessesOfType(processType);
this.callback.tooManyProcessesOfType(processType);
return;
}
// We need to lock access to the Time array, try up to 1 second
long[] times1 = new long[data.getMaxNumberOfProcessesAllowed()];
if (!lock(data, 1000000)) {
this.callback.tooManyProcessesOfType(processType);
return;
}
//try {Jvm.pause(60L*1000L);} catch (InterruptedException e) {}
//we've got the lock, now copy the array
try {
for (int i = 0; i < times1.length; i++) {
times1[i] = data.getTimeAt(i);
}
} finally {
//and release the lock
unlock(data);
}
pause(3L * timeUpdateInterval);
if (!lock(data, 1000000)) {
this.callback.tooManyProcessesOfType(processType);
return;
}
boolean alreadyUnlocked = false;
try {
for (int i = 0; i < times1.length; i++) {
if (data.getTimeAt(i) == times1[i]) {
//we have an index which has not been updated in 3x the
//time interval, so we have a spare slot - use this slot
this.startTime = System.currentTimeMillis();
this.starttimedata.get(processType).setTimeAt(i, this.startTime);
if (updateTheSharedMap(processType, i, data)) {
this.localUpdates.put(processType, new Integer(i));
unlock(data);
alreadyUnlocked = true;
this.callback.thisProcessOfTypeHasStartedAtSlot(processType, i);
return;
}
}
}
} finally {
//and release the lock
if (!alreadyUnlocked) {
unlock(data);
}
}
this.callback.tooManyProcessesOfType(processType);
}
/**
* Set the maximum number of processes of type processType that can run
* concurrently on the same machine
*
* @param processType - any string, specifies the type of process that is limited to
* maxNumberOfProcessesAllowed processes
* @param maxNumberOfProcessesAllowed - any positive number, specifies the maximum number of
* processes of this type that can run concurrently on the same
* machine
*/
public void setMaxNumberOfProcessesOfType(String processType, int maxNumberOfProcessesAllowed) {
if (maxNumberOfProcessesAllowed <= 0) {
throw new IllegalArgumentException("maxNumberOfProcessesAllowed must be a positive number, not " + maxNumberOfProcessesAllowed);
}
Data data = Values.newNativeReference(Data.class);
this.timedata.put(processType, data);
this.theSharedMap.acquireUsing(processType, data);
if (data.getMaxNumberOfProcessesAllowed() != maxNumberOfProcessesAllowed) {
//it's either a new object, set to 0, or
//another process set it to an invalid value
if (data.compareAndSwapMaxNumberOfProcessesAllowed(0, maxNumberOfProcessesAllowed)) {
//What we expected, everything's good
} else {
//something else set a value, if it's not 2 we've got a conflict
if (data.getMaxNumberOfProcessesAllowed() != maxNumberOfProcessesAllowed) {
throw new IllegalArgumentException("The existing shared map already specifies that the maximum number of processes allowed is " + data.getMaxNumberOfProcessesAllowed() + " and changing that to " + maxNumberOfProcessesAllowed + " is not supported");
}
}
}
String name = processType + '#';
data = Values.newNativeReference(Data.class);
this.starttimedata.put(processType, data);
this.processTypeToStartTimeType.put(processType, name);
this.theSharedMap.acquireUsing(name, data);
//this time just set it, we've done the guarding with the other value
if (data.getMaxNumberOfProcessesAllowed() == 0) {
data.setMaxNumberOfProcessesAllowed(maxNumberOfProcessesAllowed);
}
}
/**
* Assumes that the data object is non-null and already locked
* If true is returned, the update has been applied, otherwise
* this slot is conflicted
*/
private boolean updateTheSharedMap(String processType, int index, Data data) {
long timenow = System.currentTimeMillis();
data.setTimeAt(index, timenow);
Data startTimesData = this.starttimedata.get(processType);
if (this.startTime != startTimesData.getTimeAt(index)) {
//something else is updating this index, so we assume we're
//conflicted and give up - with a callback
this.callback.anotherProcessHasHijackedThisSlot(processType, index);
return false;
}
if (this.lastStartTimes != null) {
for (int i = 0; i < this.lastStartTimes.length; i++) {
if ((i != index) && (this.lastStartTimes[i] != startTimesData.getTimeAt(i))) {
this.callback.anotherProcessHasStartedOnSlot(processType, i, startTimesData.getTimeAt(i));
}
}
} else {
this.lastStartTimes = new long[startTimesData.getMaxNumberOfProcessesAllowed()];
}
for (int i = 0; i < this.lastStartTimes.length; i++) {
this.lastStartTimes[i] = startTimesData.getTimeAt(i);
}
return true;
}
private boolean lock(Data data, int microsecondsToTry) {
return AcquisitionStrategies
.<ReadWriteLockingStrategy>spinLoop(microsecondsToTry, TimeUnit.MICROSECONDS)
.acquire(TryAcquireOperations.writeLock(),
VanillaReadWriteWithWaitsLockingStrategy.instance(),
checkedBytesStoreAccess(),
((Byteable) data).bytesStore(),
((Byteable) data).offset());
}
private void unlock(Data data) {
try {
VanillaReadWriteWithWaitsLockingStrategy.instance()
.writeUnlock(checkedBytesStoreAccess(),
((Byteable) data).bytesStore(), ((Byteable) data).offset());
} catch (IllegalMonitorStateException e) {
//odd, but we'll be unlocked either way
System.out.println("Unexpected state: " + e);
e.printStackTrace();
}
}
/**
* The Callback interface holds all the calls that can be made by the
* process instance limiter.
*/
public interface Callback {
/**
* Called when there are already the specified number of processes of
* the given type running, and this process is one too many.
*
* @param processType - the name of the type of process being limited
*/
void tooManyProcessesOfType(String processType);
/**
* Called when there is a lock conflict in the limiter
* which probably means the process must exit
*
* @param processType - the name of the type of process being limited
* @param index - the slot number held by the other process
*/
void lockConflictDetected(String processType, int index);
/**
* Called when another process has started and successfully acquired a
* slot that allows it to continue running.
*
* @param processType - the name of the type of process being limited
* @param slot - the slot number held by the other process
* @param startTime - the start timestamp of the other process
*/
void anotherProcessHasStartedOnSlot(String processType, int slot, long startTime);
/**
* Called when this process has started and successfully acquired a slot
* that allows it to continue running.
*
* @param processType - the name of the type of process being limited
* @param slot - the slot number held by this process
*/
void thisProcessOfTypeHasStartedAtSlot(String processType, int slot);
/**
* Called if the process was started but there was no data defined in
* the shared map that would limit processes of this type.
*
* @param processType - the name of the type of process being limited
*/
void noDefinitionForProcessesOfType(String processType);
/**
* Called when another process somehow managed to steal the slot that
* this process had acquired.
*
* @param processType - the name of the type of process being limited
* @param slot - the slot number held by this process
*/
void anotherProcessHasHijackedThisSlot(String processType, int slot);
}
/**
* The Data object holds an array of timestamps and a maximum number of
* processes allowed to be running concurrently
* <p/>
* <p>The Timelock field is just for locking the time field
*/
public interface Data {
/**
* Ensure lock goes first in flyweight layout, to apply external locking
*/
@Group(0)
long getTimeLock();
void setTimeLock(long timeLock);
@Group(1)
@Array(length = 50)
void setTimeAt(int index, long time);
long getTimeAt(int index);
@Group(1)
int getMaxNumberOfProcessesAllowed();
void setMaxNumberOfProcessesAllowed(int num);
boolean compareAndSwapMaxNumberOfProcessesAllowed(int expected, int value);
}
/**
* A default implementation of the Callback interface, which prints an
* information line to System.out for each callback, and calls
* System.exit(0) for those methods which don't leave the current process
* owning a slot.
*/
public static class DefaultCallback implements Callback {
ProcessInstanceLimiter limiter;
public DefaultCallback(ProcessInstanceLimiter limiter) {
this.limiter = limiter;
}
public DefaultCallback() {
}
public ProcessInstanceLimiter getLimiter() {
return limiter;
}
public void setLimiter(ProcessInstanceLimiter limiter) {
this.limiter = limiter;
}
public void tooManyProcessesOfType(String processType) {
System.out.println("Sufficient processes (" + this.limiter.getMaxNumberOfProcessesAllowedFor(processType) + ") of type " + processType + " have already been started, so exiting this process");
System.exit(0);
}
public void noDefinitionForProcessesOfType(String processType) {
System.out.println("No definition for processes of type " + processType + " has been set, so exiting this process");
System.exit(0);
}
public void anotherProcessHasHijackedThisSlot(String processType, int slot) {
System.out.println("Another process of type " + processType + " has hijacked the slot (" + slot + "/" + this.limiter.getMaxNumberOfProcessesAllowedFor(processType) + ") allocated to this process, so exiting this process");
System.exit(0);
}
public void thisProcessOfTypeHasStartedAtSlot(String processType, int slot) {
System.out.println("This process of type " + processType + " has started at slot " + slot + "/" + this.limiter.getMaxNumberOfProcessesAllowedFor(processType));
}
public void anotherProcessHasStartedOnSlot(String processType, int slot, long startTime) {
System.out.println("Another process of type " + processType + " has started at slot " + slot + "/" + this.limiter.getMaxNumberOfProcessesAllowedFor(processType) + " at time " + new Date(startTime));
}
public void lockConflictDetected(String processType, int slot) {
System.out.println("The limiter lock has become conflicted for type " + processType + " on slot (" + slot + "/" + this.limiter.getMaxNumberOfProcessesAllowedFor(processType) + ") allocated to this process, so exiting this process");
System.exit(0);
}
}
}