net.timewalker.ffmq4.local.session.LocalSession Maven / Gradle / Ivy
/*
* This file is part of FFMQ.
*
* FFMQ 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 2 of the License, or
* (at your option) any later version.
*
* FFMQ 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 FFMQ; if not, write to the Free Software
* Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
*/
package net.timewalker.ffmq4.local.session;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.Vector;
import javax.jms.Destination;
import javax.jms.IllegalStateException;
import javax.jms.InvalidDestinationException;
import javax.jms.JMSException;
import javax.jms.MessageConsumer;
import javax.jms.MessageProducer;
import javax.jms.Queue;
import javax.jms.QueueBrowser;
import javax.jms.Session;
import javax.jms.TemporaryQueue;
import javax.jms.TemporaryTopic;
import javax.jms.Topic;
import javax.jms.TopicSubscriber;
import net.timewalker.ffmq4.FFMQConstants;
import net.timewalker.ffmq4.FFMQException;
import net.timewalker.ffmq4.common.destination.TemporaryQueueRef;
import net.timewalker.ffmq4.common.destination.TemporaryTopicRef;
import net.timewalker.ffmq4.common.message.AbstractMessage;
import net.timewalker.ffmq4.common.session.AbstractSession;
import net.timewalker.ffmq4.local.FFMQEngine;
import net.timewalker.ffmq4.local.MessageLock;
import net.timewalker.ffmq4.local.MessageLockSet;
import net.timewalker.ffmq4.local.TransactionItem;
import net.timewalker.ffmq4.local.TransactionSet;
import net.timewalker.ffmq4.local.connection.LocalConnection;
import net.timewalker.ffmq4.local.destination.AbstractLocalDestination;
import net.timewalker.ffmq4.local.destination.LocalQueue;
import net.timewalker.ffmq4.local.destination.notification.NotificationProxy;
import net.timewalker.ffmq4.security.Action;
import net.timewalker.ffmq4.security.Resource;
import net.timewalker.ffmq4.utils.Committable;
import net.timewalker.ffmq4.utils.ErrorTools;
import net.timewalker.ffmq4.utils.StringTools;
import net.timewalker.ffmq4.utils.concurrent.SynchronizationBarrier;
import net.timewalker.ffmq4.utils.id.IntegerID;
import net.timewalker.ffmq4.utils.id.UUIDProvider;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
/**
* Implementation of a local JMS {@link Session}
*/
public class LocalSession extends AbstractSession
{
private static final Log log = LogFactory.getLog(LocalSession.class);
// Attributes
protected FFMQEngine engine;
// Runtime
private List pendingPuts = new Vector<>();
private TransactionSet transactionSet = new TransactionSet();
private boolean debugEnabled = log.isDebugEnabled();
// For internal use by the remote layer
protected NotificationProxy notificationProxy;
// Message stats
private long consumedCount;
private long producedCount;
/**
* Constructor
*/
public LocalSession( IntegerID id , LocalConnection connection , FFMQEngine engine , boolean transacted , int acknowlegdeMode )
{
super(id,connection,transacted,acknowlegdeMode);
this.engine = engine;
}
/**
* @param notificationProxy the notificationProxy to set
*/
public final void setNotificationProxy(NotificationProxy notificationProxy)
{
this.notificationProxy = notificationProxy;
}
/**
* @return the notificationProxy
*/
public final NotificationProxy getNotificationProxy()
{
return notificationProxy;
}
/**
* Called from producers when sending a message
* @param message message to dispatch
* @throws JMSException
*/
public final void dispatch( AbstractMessage message ) throws JMSException
{
// Security
LocalConnection conn = (LocalConnection)getConnection();
if (conn.isSecurityEnabled())
{
Destination destination = message.getJMSDestination();
if (destination instanceof Queue)
{
String queueName = ((Queue)destination).getQueueName();
if (conn.isRegisteredTemporaryQueue(queueName))
{
// OK, temporary destination
}
else
if (queueName.equals(FFMQConstants.ADM_REQUEST_QUEUE))
{
conn.checkPermission(Resource.SERVER, Action.REMOTE_ADMIN);
}
else
if (queueName.equals(FFMQConstants.ADM_REPLY_QUEUE))
{
// Only the internal admin thread can produce on this queue
if (conn.getSecurityContext() != null)
throw new FFMQException("Access denied to administration queue "+queueName,"ACCESS_DENIED");
}
else
{
// Standard queue
conn.checkPermission(destination,Action.PRODUCE);
}
}
else
if (destination instanceof Topic)
{
String topicName = ((Topic)destination).getTopicName();
if (conn.isRegisteredTemporaryTopic(topicName))
{
// OK, temporary destination
}
else
{
// Standard topic
conn.checkPermission(destination,Action.PRODUCE);
}
}
else
throw new InvalidDestinationException("Unsupported destination : "+destination);
}
if (debugEnabled)
log.debug(this+" [PUT] in "+message.getJMSDestination()+" - "+message);
externalAccessLock.readLock().lock();
try
{
checkNotClosed();
pendingPuts.add(message);
if (!transacted)
commitUpdates(false, null, true); // FIXME Async commit ?
}
finally
{
externalAccessLock.readLock().unlock();
}
}
/*
* (non-Javadoc)
* @see javax.jms.Session#commit()
*/
@Override
public final void commit() throws JMSException
{
commit(true,null);
}
/**
* Commit pending put/get operations in this session
* @param commitGets
* @param deliveredMessageIDs
* @throws JMSException
*/
public final void commit( boolean commitGets , List deliveredMessageIDs ) throws JMSException
{
if (!transacted)
throw new IllegalStateException("Session is not transacted"); // [JMS SPEC]
externalAccessLock.readLock().lock();
try
{
checkNotClosed();
commitUpdates(commitGets,deliveredMessageIDs,true);
}
finally
{
externalAccessLock.readLock().unlock();
}
}
/* (non-Javadoc)
* @see javax.jms.Session#rollback()
*/
@Override
public final void rollback() throws JMSException
{
rollback(true, null);
}
/**
* Rollback pending put/get operations in this session
* @param rollbackGets
* @param deliveredMessageIDs
* @throws JMSException
*/
public final void rollback( boolean rollbackGets, List deliveredMessageIDs ) throws JMSException
{
if (!transacted)
throw new IllegalStateException("Session is not transacted"); // [JMS SPEC]
externalAccessLock.readLock().lock();
try
{
checkNotClosed();
rollbackUpdates(true,rollbackGets, deliveredMessageIDs);
}
finally
{
externalAccessLock.readLock().unlock();
}
}
/**
* Rollback undelivered get operations in this session
* @param undeliveredMessageIDs
* @throws JMSException
*/
public final void rollbackUndelivered( List undeliveredMessageIDs ) throws JMSException
{
externalAccessLock.readLock().lock();
try
{
checkNotClosed();
rollbackUpdates(false,true, undeliveredMessageIDs);
}
finally
{
externalAccessLock.readLock().unlock();
}
}
private AbstractLocalDestination getLocalDestination( AbstractMessage message ) throws JMSException
{
Destination destination = message.getJMSDestination();
if (destination instanceof Queue)
{
Queue queueRef = (Queue)destination;
return engine.getLocalQueue(queueRef.getQueueName());
}
else
if (destination instanceof Topic)
{
Topic topicRef = (Topic)destination;
return engine.getLocalTopic(topicRef.getTopicName());
}
else
throw new InvalidDestinationException("Unsupported destination : "+destination);
}
private List computeLocalTargetDestinations( List pendingPuts , List queuesWithGet ) throws JMSException
{
int initialSize = Math.max((pendingPuts != null ? pendingPuts.size() : 0)+
(queuesWithGet != null ? queuesWithGet.size() : 0),16);
List targetCommitables = new ArrayList<>(initialSize);
if (queuesWithGet != null)
targetCommitables.addAll(queuesWithGet);
if (pendingPuts != null)
{
for (int i = 0 ; i < pendingPuts.size() ; i++)
{
AbstractMessage msg = pendingPuts.get(i);
AbstractLocalDestination destination = getLocalDestination(msg);
if (!targetCommitables.contains(destination))
targetCommitables.add(destination);
}
}
// Sort list (important to avoid deadlocks when locking destinations for update)
Collections.sort(targetCommitables, DESTINATION_COMPARATOR);
return targetCommitables;
}
private void commitUpdates( boolean commitGets , List deliveredMessageIDs , boolean commitPuts ) throws JMSException
{
SynchronizationBarrier commitBarrier = null;
List queuesWithGet = null;
MessageLockSet locks = null;
JMSException putFailure = null;
Set committables = new HashSet<>();
// 1 - Build a list of queues updated in get operations
if (commitGets && transactionSet.size() > 0)
{
if (deliveredMessageIDs != null)
queuesWithGet = transactionSet.updatedQueues(deliveredMessageIDs);
else
queuesWithGet = transactionSet.updatedQueues();
}
// 2 - Build a list of all target destinations
List targetDestinations;
try
{
targetDestinations = computeLocalTargetDestinations(commitPuts ? pendingPuts : null,queuesWithGet);
}
catch (FFMQException e)
{
// May happen if the destination of a pending put is invalid (disappeared or could not be auto-created for whatever reason)
if (commitPuts && !transacted)
{
pendingPuts.clear(); // Make sure we discard the messages on failure, otherwise they will pile-up, which is unexpected in non-transacted mode
ErrorTools.log(e, log);
}
throw e; // Abort immediately
}
// 3 - Lock target destinations
for (int i = 0; i < targetDestinations.size(); i++)
{
Committable committable = targetDestinations.get(i);
committable.openTransaction();
}
try
{
if (commitPuts)
{
// 4 - Try sending all pending queue messages first (because this may fail if a queue is full)
synchronized (pendingPuts)
{
if (!pendingPuts.isEmpty())
{
int pendingSize = pendingPuts.size();
locks = new MessageLockSet(pendingSize);
if (debugEnabled)
log.debug(this+" - COMMIT [PUT] "+pendingPuts.size()+" message(s)");
// Put messages in locked state. They will be unlocked after proper commit.
int produced = 0;
try
{
for (int i = 0; i < pendingSize; i++)
{
AbstractMessage message = pendingPuts.get(i);
AbstractLocalDestination targetDestination = getLocalDestination(message);
if (targetDestination.putLocked(message, this, locks))
committables.add(targetDestination);
produced++;
}
// All messages successfully pushed
pendingPuts.clear();
}
catch (JMSException e)
{
if (transacted)
{
// Oops, something went wrong, we need to rollback what we have done yet
for (int i = 0; i < locks.size(); i++)
{
MessageLock item = locks.get(i);
item.getDestination().removeLocked(item);
}
produced = 0;
// Store failure (will be re-thrown later after transaction commit, see below)
putFailure = e;
}
else
{
pendingPuts.clear(); // Make sure we discard the messages on failure, otherwise they will pile-up, which is unexpected in non-transacted mode
ErrorTools.log(e, log);
// Store failure (will be re-thrown later after transaction commit, see below)
putFailure = e;
}
}
producedCount += produced;
}
}
}
// 5 - Commit pending get messages, i.e. delete them from destinations
if (queuesWithGet != null && putFailure == null)
{
TransactionItem[] pendingGets;
if (deliveredMessageIDs != null)
{
// Commit only delivered messages
if (debugEnabled)
log.debug(this+" - COMMIT [GET] "+deliveredMessageIDs.size()+" message(s)");
pendingGets = transactionSet.clear(deliveredMessageIDs);
}
else
{
// Commit the whole transaction set
if (debugEnabled)
log.debug(this+" - COMMIT [GET] "+transactionSet.size()+" message(s)");
pendingGets = transactionSet.clear();
}
for (int i = 0; i < queuesWithGet.size(); i++)
{
LocalQueue localQueue = queuesWithGet.get(i);
if (localQueue.remove(this,pendingGets))
committables.add(localQueue);
consumedCount++;
}
}
// 6 - Commit destinations
if (committables.size() > 0)
{
commitBarrier = new SynchronizationBarrier();
Iterator commitables = committables.iterator();
while (commitables.hasNext())
{
Committable commitable = commitables.next();
commitable.commitChanges(commitBarrier);
}
}
}
finally
{
// 7 - Release locks
for (int i = 0; i < targetDestinations.size(); i++)
{
Committable committable = targetDestinations.get(i);
committable.closeTransaction();
}
}
// 8 - If something went wrong during put operations, stop here
if (putFailure != null)
throw putFailure;
// 9 - Wait for commit barrier if necessary
if (commitBarrier != null)
{
try
{
commitBarrier.waitFor();
}
catch (InterruptedException e)
{
throw new JMSException("Commit barrier was interrupted");
}
}
// 10 - Unlock and deliver messages
if (locks != null)
{
for (int i = 0; i < locks.size(); i++)
{
MessageLock item = locks.get(i);
item.getDestination().unlockAndDeliver(item);
}
}
}
private void rollbackUpdates( boolean rollbackPuts , boolean rollbackGets, List deliveredMessageIDs ) throws JMSException
{
// Clear pending put messages
if (rollbackPuts && transacted)
{
if (!pendingPuts.isEmpty())
{
if (debugEnabled)
log.debug(this+" - ROLLBACK [PUT] "+pendingPuts.size()+" message(s)");
pendingPuts.clear();
}
}
// Rollback pending get messages
if (rollbackGets && transactionSet.size() > 0)
{
SynchronizationBarrier commitBarrier = null;
Set committables = new HashSet<>();
// 1 - Check for pending get operations
TransactionItem[] pendingGets;
if (deliveredMessageIDs != null)
{
// Rollback only delivered messages
if (debugEnabled)
log.debug(this+" - ROLLBACK [GET] "+deliveredMessageIDs.size()+" message(s)");
pendingGets = transactionSet.clear(deliveredMessageIDs);
}
else
{
// Rollback the whole transaction set
if (debugEnabled)
log.debug(this+" - ROLLBACK [GET] "+transactionSet.size()+" message(s)");
pendingGets = transactionSet.clear();
}
List queuesWithGet = computeUpdatedQueues(pendingGets);
MessageLockSet locks = new MessageLockSet(pendingGets.length);
// 2 - Compute target destinations lists
List targetDestinations = computeLocalTargetDestinations(null,queuesWithGet);
// 3 - Lock target destinations
for (int i = 0; i < targetDestinations.size(); i++)
{
Committable committable = targetDestinations.get(i);
committable.openTransaction();
}
try
{
// 4 - Redeliver locked messages to queues
for (int i = 0; i < queuesWithGet.size(); i++)
{
LocalQueue localQueue = queuesWithGet.get(i);
if (localQueue.redeliverLocked(pendingGets,locks))
committables.add(localQueue);
}
// 5 - Commit destinations
if (committables.size() > 0)
{
commitBarrier = new SynchronizationBarrier();
Iterator commitables = committables.iterator();
while (commitables.hasNext())
{
Committable commitable = commitables.next();
commitable.commitChanges(commitBarrier);
}
}
}
finally
{
// 6 - Release locks
for (int i = 0; i < targetDestinations.size(); i++)
{
Committable committable = targetDestinations.get(i);
committable.closeTransaction();
}
}
// 7 - Wait for commit barrier if necessary
if (commitBarrier != null)
{
try
{
commitBarrier.waitFor();
}
catch (InterruptedException e)
{
throw new JMSException("Commit barrier was interrupted");
}
}
// 8 - Unlock and re-deliver messages if necessary
for (int i = 0; i < locks.size(); i++)
{
MessageLock item = locks.get(i);
item.getDestination().unlockAndDeliver(item);
}
}
}
private List computeUpdatedQueues( TransactionItem[] pendingGets )
{
List updatedQueues = new ArrayList<>(Math.max(pendingGets.length,16));
for (int i = 0 ; i < pendingGets.length ; i++)
{
LocalQueue localQueue = pendingGets[i].getDestination();
if (!updatedQueues.contains(localQueue))
updatedQueues.add(localQueue);
}
return updatedQueues;
}
private boolean hasPendingUpdates()
{
return transactionSet.size() > 0 || pendingPuts.size() > 0;
}
/**
* @return the transactionSet
*/
protected final TransactionSet getTransactionSet()
{
return transactionSet;
}
/* (non-Javadoc)
* @see net.timewalker.ffmq4.common.session.AbstractSession#onSessionClose()
*/
@Override
protected void onSessionClose()
{
// Rollback updates
try
{
if (hasPendingUpdates())
rollbackUpdates(true,true, null);
}
catch (JMSException e)
{
ErrorTools.log(e, log);
}
super.onSessionClose();
}
/* (non-Javadoc)
* @see javax.jms.Session#createBrowser(javax.jms.Queue, java.lang.String)
*/
@Override
public QueueBrowser createBrowser(Queue queueRef, String messageSelector) throws JMSException
{
return createBrowser(idProvider.createID(), queueRef, messageSelector);
}
public QueueBrowser createBrowser(IntegerID browserId,Queue queueRef, String messageSelector) throws JMSException
{
externalAccessLock.readLock().lock();
try
{
checkNotClosed();
LocalQueue localQueue = engine.getLocalQueue(queueRef.getQueueName());
// Check temporary destinations scope (JMS Spec 4.4.3 p2)
checkTemporaryDestinationScope(localQueue);
LocalQueueBrowser browser = new LocalQueueBrowser(this,localQueue,messageSelector,browserId);
registerBrowser(browser);
return browser;
}
finally
{
externalAccessLock.readLock().unlock();
}
}
/* (non-Javadoc)
* @see javax.jms.Session#createConsumer(javax.jms.Destination, java.lang.String, boolean)
*/
@Override
public MessageConsumer createConsumer(Destination destination, String messageSelector, boolean noLocal) throws JMSException
{
return createConsumer(idProvider.createID(), destination, messageSelector, noLocal);
}
/**
* Create a consumer with the given id
*/
public MessageConsumer createConsumer(IntegerID consumerId,Destination destination, String messageSelector, boolean noLocal) throws JMSException
{
externalAccessLock.readLock().lock();
try
{
checkNotClosed();
LocalMessageConsumer consumer = new LocalMessageConsumer(engine,this,destination,messageSelector,noLocal,consumerId,null);
registerConsumer(consumer);
consumer.initDestination();
return consumer;
}
finally
{
externalAccessLock.readLock().unlock();
}
}
/* (non-Javadoc)
* @see javax.jms.Session#createDurableSubscriber(javax.jms.Topic, java.lang.String, java.lang.String, boolean)
*/
@Override
public TopicSubscriber createDurableSubscriber(Topic topic, String subscriptionName, String messageSelector, boolean noLocal) throws JMSException
{
return createDurableSubscriber(idProvider.createID(), topic, subscriptionName, messageSelector, noLocal);
}
public TopicSubscriber createDurableSubscriber(IntegerID consumerId, Topic topic, String subscriptionName, String messageSelector, boolean noLocal) throws JMSException
{
if (StringTools.isEmpty(subscriptionName))
throw new FFMQException("Empty subscription name","INVALID_SUBSCRIPTION_NAME");
externalAccessLock.readLock().lock();
try
{
checkNotClosed();
// Get the client ID
String clientID = connection.getClientID();
// Create the consumer
String subscriberId = clientID+"-"+subscriptionName;
LocalDurableTopicSubscriber subscriber = new LocalDurableTopicSubscriber(engine,this,topic,messageSelector,noLocal,consumerId,subscriberId);
registerConsumer(subscriber);
subscriber.initDestination();
// Register the subscription
engine.subscribe(clientID, subscriptionName);
return subscriber;
}
finally
{
externalAccessLock.readLock().unlock();
}
}
/* (non-Javadoc)
* @see javax.jms.Session#createProducer(javax.jms.Destination)
*/
@Override
public MessageProducer createProducer(Destination destination) throws JMSException
{
externalAccessLock.readLock().lock();
try
{
checkNotClosed();
LocalMessageProducer producer = new LocalMessageProducer(this,destination,idProvider.createID());
registerProducer(producer);
return producer;
}
finally
{
externalAccessLock.readLock().unlock();
}
}
/* (non-Javadoc)
* @see javax.jms.Session#recover()
*/
@Override
public final void recover() throws JMSException
{
recover(null);
}
/**
* @see #rollback(boolean, List)
*/
public final void recover( List deliveredMessageIDs ) throws JMSException
{
externalAccessLock.readLock().lock();
try
{
checkNotClosed();
if (transacted)
throw new IllegalStateException("Session is transacted"); // [JMS SPEC]
rollbackUpdates(true,true, deliveredMessageIDs);
}
finally
{
externalAccessLock.readLock().unlock();
}
}
/* (non-Javadoc)
* @see javax.jms.Session#unsubscribe(java.lang.String)
*/
@Override
public void unsubscribe(String subscriptionName) throws JMSException
{
externalAccessLock.readLock().lock();
try
{
checkNotClosed();
if (StringTools.isEmpty(subscriptionName))
throw new FFMQException("Empty subscription name","INVALID_SUBSCRIPTION_NAME");
// Remove remaining subscriptions on all topics
engine.unsubscribe(connection.getClientID(), subscriptionName);
}
finally
{
externalAccessLock.readLock().unlock();
}
}
/* (non-Javadoc)
* @see javax.jms.Session#createTemporaryQueue()
*/
@Override
public TemporaryQueue createTemporaryQueue() throws JMSException
{
externalAccessLock.readLock().lock();
try
{
checkNotClosed();
String queueName = "TEMP-QUEUE-"+UUIDProvider.getInstance().getShortUUID();
engine.createTemporaryQueue(queueName);
connection.registerTemporaryQueue(queueName);
return new TemporaryQueueRef(connection,queueName);
}
finally
{
externalAccessLock.readLock().unlock();
}
}
/* (non-Javadoc)
* @see javax.jms.Session#createTemporaryTopic()
*/
@Override
public TemporaryTopic createTemporaryTopic() throws JMSException
{
externalAccessLock.readLock().lock();
try
{
checkNotClosed();
String topicName = "TEMP-TOPIC-"+UUIDProvider.getInstance().getShortUUID();
engine.createTemporaryTopic(topicName);
connection.registerTemporaryTopic(topicName);
return new TemporaryTopicRef(connection,topicName);
}
finally
{
externalAccessLock.readLock().unlock();
}
}
/*
* (non-Javadoc)
* @see net.timewalker.ffmq4.common.session.AbstractSession#acknowledge()
*/
@Override
public final void acknowledge() throws JMSException
{
acknowledge(null);
}
/**
* @see #commit(boolean,List)
*/
public final void acknowledge( List deliveredMessageIDs ) throws JMSException
{
if (transacted)
throw new IllegalStateException("Session is transacted"); // [JMS SPEC]
externalAccessLock.readLock().lock();
try
{
checkNotClosed();
commitUpdates(true,deliveredMessageIDs,false);
}
finally
{
externalAccessLock.readLock().unlock();
}
}
/**
* Delete a queue
* @param queueName
* @throws JMSException
*/
protected final void deleteQueue( String queueName ) throws JMSException
{
transactionSet.removeUpdatesForQueue(queueName);
engine.deleteQueue(queueName);
}
/**
* Get the number of messages actually produced by this session
* @return the number of messages actually produced by this session
*/
public final long getProducedCount()
{
return producedCount;
}
/**
* Get the number of messages actually consumed by this session
* @return the number of messages actually consumed by this session
*/
public final long getConsumedCount()
{
return consumedCount;
}
/*
* (non-Javadoc)
* @see java.lang.Object#toString()
*/
@Override
public String toString()
{
StringBuilder sb = new StringBuilder();
sb.append(super.toString());
sb.append("(consumed=");
sb.append(consumedCount);
sb.append(",produced=");
sb.append(producedCount);
sb.append(")");
return sb.toString();
}
//----------------------------------------------------------------------
private static final DestinationComparator DESTINATION_COMPARATOR = new DestinationComparator();
private static final class DestinationComparator implements Comparator
{
/**
* Constructor
*/
public DestinationComparator()
{
super();
}
/* (non-Javadoc)
* @see java.util.Comparator#compare(java.lang.Object, java.lang.Object)
*/
@Override
public int compare(Committable c1, Committable c2)
{
int delta = c1.getName().compareTo(c2.getName());
if (delta != 0)
return delta;
return c1.getClass().getName().compareTo(c2.getClass().getName());
}
}
}
© 2015 - 2024 Weber Informatics LLC | Privacy Policy