All Downloads are FREE. Search and download functionalities are using the official Maven repository.

se.kth.iss.ug2.Ug2Client Maven / Gradle / Ivy

There is a newer version: 1.0.5
Show newest version
/*
 * MIT License
 *
 * Copyright (c) 2017 Kungliga Tekniska högskolan
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */
package se.kth.iss.ug2;

import static se.kth.iss.ug2.Ug2Protocol.ATTRIBUTE;
import static se.kth.iss.ug2.Ug2Protocol.CHANGE_LOG_ATTRIBUTE;
import static se.kth.iss.ug2.Ug2Protocol.CHANGE_LOG_CLASS;
import static se.kth.iss.ug2.Ug2Protocol.CHANGE_LOG_FACILITY;
import static se.kth.iss.ug2.Ug2Protocol.CHANGE_LOG_KTHID;
import static se.kth.iss.ug2.Ug2Protocol.CHANGE_LOG_OPERATOR;
import static se.kth.iss.ug2.Ug2Protocol.CHANGE_LOG_REQUEST;
import static se.kth.iss.ug2.Ug2Protocol.CHANGE_LOG_SESSION;
import static se.kth.iss.ug2.Ug2Protocol.CHANGE_LOG_VALUE;
import static se.kth.iss.ug2.Ug2Protocol.CLASS;
import static se.kth.iss.ug2.Ug2Protocol.CODE;
import static se.kth.iss.ug2.Ug2Protocol.DETAILS;
import static se.kth.iss.ug2.Ug2Protocol.EXCLUDE_DIRECT;
import static se.kth.iss.ug2.Ug2Protocol.EXCLUDE_INDIRECT;
import static se.kth.iss.ug2.Ug2Protocol.GROUP;
import static se.kth.iss.ug2.Ug2Protocol.KEY;
import static se.kth.iss.ug2.Ug2Protocol.KTHID;
import static se.kth.iss.ug2.Ug2Protocol.LENGTH;
import static se.kth.iss.ug2.Ug2Protocol.LOOKUPATTR;
import static se.kth.iss.ug2.Ug2Protocol.LOOKUPVALUE;
import static se.kth.iss.ug2.Ug2Protocol.MAX_TIME;
import static se.kth.iss.ug2.Ug2Protocol.MAX_VERSION;
import static se.kth.iss.ug2.Ug2Protocol.MIN_TIME;
import static se.kth.iss.ug2.Ug2Protocol.MIN_VERSION;
import static se.kth.iss.ug2.Ug2Protocol.NUMLOOKUPVALS;
import static se.kth.iss.ug2.Ug2Protocol.OBJECT;
import static se.kth.iss.ug2.Ug2Protocol.OBJECTSTATUS;
import static se.kth.iss.ug2.Ug2Protocol.OPERATION;
import static se.kth.iss.ug2.Ug2Protocol.OP_ACCUMULATE_GROUP_DATA;
import static se.kth.iss.ug2.Ug2Protocol.OP_ALL_OBJECTS_HAVING;
import static se.kth.iss.ug2.Ug2Protocol.OP_CREATE_SESSION;
import static se.kth.iss.ug2.Ug2Protocol.OP_CURRENT_VERSION;
import static se.kth.iss.ug2.Ug2Protocol.OP_FIND_OBJECTS;
import static se.kth.iss.ug2.Ug2Protocol.OP_GET_CHANGE_LOG_ENTRIES;
import static se.kth.iss.ug2.Ug2Protocol.OP_GET_DATA;
import static se.kth.iss.ug2.Ug2Protocol.OP_GET_SCHEMA;
import static se.kth.iss.ug2.Ug2Protocol.OP_MEMBERSHIP;
import static se.kth.iss.ug2.Ug2Protocol.OP_OBJECTS_MATCHING;
import static se.kth.iss.ug2.Ug2Protocol.OP_PING;
import static se.kth.iss.ug2.Ug2Protocol.OP_PRE_PING;
import static se.kth.iss.ug2.Ug2Protocol.OP_SET_DATA;
import static se.kth.iss.ug2.Ug2Protocol.OP_TERMINATE_SESSION;
import static se.kth.iss.ug2.Ug2Protocol.PASSWORD;
import static se.kth.iss.ug2.Ug2Protocol.PROTOCOL_VERSION_MAJOR;
import static se.kth.iss.ug2.Ug2Protocol.PROTOCOL_VERSION_TAG;
import static se.kth.iss.ug2.Ug2Protocol.REQUESTID;
import static se.kth.iss.ug2.Ug2Protocol.SERVER_ID;
import static se.kth.iss.ug2.Ug2Protocol.SESSIONID;
import static se.kth.iss.ug2.Ug2Protocol.SESSION_KEY_AGE_MAX;
import static se.kth.iss.ug2.Ug2Protocol.SESSION_KEY_VERSION;
import static se.kth.iss.ug2.Ug2Protocol.STATUS;
import static se.kth.iss.ug2.Ug2Protocol.STATUS_AUTHFAIL;
import static se.kth.iss.ug2.Ug2Protocol.STATUS_EXISTS;
import static se.kth.iss.ug2.Ug2Protocol.STATUS_FAIL;
import static se.kth.iss.ug2.Ug2Protocol.STATUS_ILLEGALDATA;
import static se.kth.iss.ug2.Ug2Protocol.STATUS_NOTFOUND;
import static se.kth.iss.ug2.Ug2Protocol.STATUS_NOTPERMITTED;
import static se.kth.iss.ug2.Ug2Protocol.STATUS_NOTUNIQUE;
import static se.kth.iss.ug2.Ug2Protocol.STATUS_OK;
import static se.kth.iss.ug2.Ug2Protocol.STATUS_SERVERERROR;
import static se.kth.iss.ug2.Ug2Protocol.STATUS_WARNING;
import static se.kth.iss.ug2.Ug2Protocol.SYSTEM;
import static se.kth.iss.ug2.Ug2Protocol.TIME_OFFSET;
import static se.kth.iss.ug2.Ug2Protocol.USER;
import static se.kth.iss.ug2.Ug2Protocol.VALUE;
import static se.kth.iss.ug2.Ug2Protocol.VERSION;

import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Properties;
import java.util.ResourceBundle;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.squareup.okhttp.OkHttpClient;

/**
 * Connection to (a session at) a UG server
 */
public class Ug2Client {

    public static final byte NOT_STARTED = -1;
    public static final byte NORMAL = 0;
    public static final byte TERMINATED = 1;

    private static final ScheduledExecutorService keepAliveService = Executors.newSingleThreadScheduledExecutor();
    private static final Object keepAliveLock = new Object();
    private static final Logger log = LoggerFactory.getLogger(Ug2Client.class);
    private static final String MISSING = "";
    private static Ug2Client theInstance;
    private static KeepAlive keepAlive = null;
    private static long maxUnusedTime = 60;

    private final Properties configuration = new Properties();
    private final AtomicInteger state = new AtomicInteger(NOT_STARTED);

    private Transport channel; // Connection that we use to talk to the UG2 server through.
    private Instant lastAccessed = Instant.EPOCH;
    private String serverSessionId = null;
    private String requestIdBase;
    private Long sessionId;
    private String system;
    private byte[] systemKey;
    private byte[] _sessionKey;
    private long _sessionKeyCreationTime;
    private long _sessionKeyVersion;
    private long sessionKeyAgeMax;
    private long serverTimeAdjust = 0;
    private long keepAliveInterval = 30;

    private void clearSession() {
        sessionId = null;
        _sessionKey = null;
        _sessionKeyVersion = -1;
    }

    /**
     * Clears out old session information and creates a new session. The supplied
     * {@code system} name & {@code password} will be used for authentication with UG.
     * The {@link Transport} will be used for communication with UG and the {@code replyId}
     * will be used to identify requests.
     *
     * @param system    the system name that will be used for authentication.
     * @param password  the password that will be used for authentication.
     * @param channel   the Ug2Channel that will be used for communicating with UG.
     * @param requestId the request id that will be used for identifying requests, ignored if {@code null}.
     * @throws Ug2Exception if a new session can't be created.
     */
    private void createCleanSession(String system, String password, Transport channel, String requestId)
            throws Ug2Exception {
        clearSession();
        createSession(system, password, requestId, channel);
        log.debug("New Ug2Client created. Session id: " + sessionId());
    }

    /**
     * Loads the Ug2Client configuration. First tries to load the configuration
     * from the file system, and if that fails from the classpath. If the
     * supplied {@code configurationName} doesn't end with ".properties", it is
     * appended to the name.
     *
     * @param configurationName the name of the configuration to load, either
     *                          the path to a file or a resource on the classpath.
     * @throws Ug2Exception if the configuration can't be found or if it can't
     *                      be read.
     */
    private void loadConfiguration(String configurationName) throws Ug2Exception {
        String originalConfigurationName = configurationName;
        if (!configurationName.endsWith(".properties")) {
            configurationName = configurationName + ".properties";
        }
        File configurationFile = new File(configurationName);

        InputStream inputStream;
        if (configurationFile.canRead()) {
            try {
                inputStream = new FileInputStream(configurationName);
            } catch (FileNotFoundException ex) {
                throw new Ug2Exception("Configuration file " + configurationName + " not found", ex);
            }
        } else {
            inputStream = getClass().getResourceAsStream(configurationName);
            if (inputStream == null) {
                inputStream = ClassLoader.getSystemClassLoader().getResourceAsStream(configurationName);
            }
            if (inputStream == null) {
                ResourceBundle conf = ResourceBundle.getBundle(originalConfigurationName);
                for (String key : Collections.list(conf.getKeys())) {
                    String value = conf.getString(key);
                    configuration.setProperty(key, value);
                }
                return;
            }
        }
        try {
            configuration.load(inputStream);
        } catch (IOException ex) {
            throw new Ug2Exception("Can't load configuration properties from resource " + configurationName, ex);
        } finally {
            try {
                inputStream.close();
            } catch (Exception err) {
                log.warn("Failed to close config inputstream " + configurationName + ": " + err);
            }
        }
    }

    /**
     * Initializes the Ug2Client instance
     *
     * @param requestId the request id to use for the session.
     * @throws Ug2Exception if the {@code ugclient3.serverURL} is incorrect or a new session can't be created.
     */
    private void init(String requestId) throws Ug2Exception {

        URL serverURL;
        String url = configuration.getProperty("ugclient3.serverURL");
        try {
            serverURL = new URL(url);
        } catch (MalformedURLException ignored) {
            throw new Ug2Exception("Bad server URL: " + url, Ug2Exception.CONFIGERROR);
        }

        String system = configuration.getProperty("ugclient3.system");
        String password = configuration.getProperty("ugclient3.password");
        int socketTimeOutInSeconds = Integer.parseInt(configuration.getProperty("ugclient3.socketTimeOutInSeconds"));
        keepAliveInterval = Integer.parseInt(configuration.getProperty("ugclient3.sessionKeepAliveIntervalInSeconds"));
        maxUnusedTime = Integer.parseInt(configuration.getProperty("ugclient3.clientMaxUnusedTimeInSeconds"));
        if (keepAliveInterval > maxUnusedTime) {
            log.warn("Keep alive interval " + keepAliveInterval + " is greater than max unused time " + maxUnusedTime
                    + ", setting keep alive interval = max unused time");
            keepAliveInterval = maxUnusedTime;
        }
        OkHttpClient client = new OkHttpClient();
        client.setConnectTimeout(socketTimeOutInSeconds, TimeUnit.SECONDS);
        client.setWriteTimeout(socketTimeOutInSeconds * 1000, TimeUnit.SECONDS);
        HttpTransport channel = new HttpTransport(serverURL, client);
        createCleanSession(system, password, channel, requestId);
        if (keepAliveInterval > 0) {
            startKeepAlivePinging();
        }
    }

    /**
     * This constructor takes principal authentication data together with the
     * Ug2Channel reference to use for the new client. It tries to create a
     * session object in the UG2 server with the noOfForks token series
     * referring to it.
     * 
     * @param system the system name
     * @param password the password for the system
     * @param channel transport channel
     * @param requestId the request ID or null.
     * @throws Ug2Exception on errors.
     */
    public Ug2Client(String system, String password, Transport channel, String requestId)
            throws Ug2Exception {
        createCleanSession(system, password, channel, requestId);
    }

    /**
     * Constructs a new Ug2Client instance using the supplied {@code configurationName} and {@code requestId}.
     *
     * @param configurationName the name of the configuration to use.
     * @param requestId         the request id to use.
     * @throws Ug2Exception if the configuration can't be loaded or an error occurs during initialization.
     */
    public Ug2Client(String configurationName, String requestId) throws Ug2Exception {
        loadConfiguration(configurationName);
        init(requestId);
    }

    /**
     * Return a Ug2Client, creating a new instance if one doesn't exist, using the supplied {@code configurationName}.
     *
     * @param configurationName the name of the configuration to use. If null, "ugclient3" will be used.
     * @return a new Ug2Client.
     * @throws Ug2Exception if a new instance can't be created.
     */
    public static synchronized Ug2Client getInstance(String configurationName) throws Ug2Exception {
        if (configurationName == null) {
            configurationName = "ugclient3";
        }

        if (theInstance == null) {
            theInstance = new Ug2Client(configurationName, "INIT");

        } else {
            try {
                theInstance.ping("TEST");
            } catch (Ug2Exception ex) {
                log.warn("Failed to reuse Ug2Client instance", ex);
                theInstance.stopKeepAlivePinging();
                theInstance.setState(TERMINATED);
                theInstance = new Ug2Client(configurationName, "RECONNECT");
            }
        }

        return theInstance;
    }

    /**
     * Create a new Ug2Client, using null as the configuration name.
     *
     * @return a new Ug2Client.
     * @throws Ug2Exception if a new instance can't be created.
     */
    public static Ug2Client getInstance() throws Ug2Exception {
        return getInstance(null);
    }


    /**
     * This function grabs a connection to the UG2 server, sends the message and
     * return the received reply. All connection handling (grabbing and
     * returning) is dealth with so that the caller doesn't have to bother.
     *
     * @param request The Ug2Msg to send to the server.
     * @return The Ug2Msg received as a reply from the server.
     */
    private Ug2Msg doIt(Ug2Msg request) throws Ug2Exception {
        setDefaultRequestIdBaseIfBaseMissing(request);

        String logPrefix = "ReqId: " + request.getId() + ": ";
        StringBuilder logBuf = new StringBuilder();
        Ug2Msg reply;
        String status;
        String op = request.readStringMandatory(OPERATION);
        String requestId = request.readStringMandatory(REQUESTID);
        long reqTime;
        long respTime;

        if (op == null)
            op = MISSING;

        boolean prePing = (op.equals(OP_PRE_PING));

        if (requestId == null)
            requestId = MISSING;

        logBuf.append(logPrefix).append(" . Operation to send: ").append(op)
                .append(". Thread: ").append(Thread.currentThread().getName());

        log.debug(logPrefix + logBuf.toString());

        if (state.get() == TERMINATED) {
            /*
             * The session is terminated. Report this to the calling application.
             */
            throw new Ug2Exception("Session terminated (" + sessionId() + ")", Ug2Exception.TERMINATED);
        }

        if (!prePing && !op.equals(OP_CREATE_SESSION))
            request.addStringNoCheck(SESSIONID, sessionId.toString());

        if (serverSessionId == null) {
            if (!prePing)
                prePing(null, null, requestId);
        }
        if (serverSessionId != null)
            request.addString(SERVER_ID, this.serverSessionId);

        byte[] sessionKey;
        long sessionKeyVersion;
        long sessionKeyCreationTime;
        synchronized (this) {
            sessionKey = _sessionKey;
            sessionKeyVersion = _sessionKeyVersion;
            sessionKeyCreationTime = _sessionKeyCreationTime;
        }

        byte[] key;
        long sessionKeyAge = System.currentTimeMillis() - sessionKeyCreationTime;
        if ((sessionKey != null) && (sessionKeyAge < sessionKeyAgeMax / 3)) {
            key = sessionKey;
            request.addLong(SESSION_KEY_VERSION, sessionKeyVersion);
        } else {
            revertToSystemKey();
            key = systemKey;
        }

        try {
            boolean timeFailure;
            int nTimeFailures = 0;
            long timeAdjust = serverTimeAdjust;
            boolean ageFailure;
            int nAgeFailures = 0;
            boolean serverSessionFailure;
            int nServerSessionFailures = 0;
            boolean retry;
            do {
                timeFailure = false;
                ageFailure = false;
                serverSessionFailure = false;
                retry = false;

                request.addTime(timeAdjust);
                request.addMessageDigest();
                request.addAuthenticator(key);

                reqTime = (new Date()).getTime();
                reply = channel.rpc(request);
                respTime = (new Date()).getTime();
                if (reply == null)
                    throw new Ug2Exception("Ug2Channel object unexpectedly returned null.");

                long timeOffset = reply.readLongNoThrow(TIME_OFFSET);
                if (timeOffset != -1) {
                    updateServerTimeAdjustment(timeAdjust + timeOffset);
                    timeAdjust = (nTimeFailures * timeAdjust + timeOffset) / (nTimeFailures + 1);
                    log.info(logPrefix + "Offset " + timeOffset + ", changing local adjustment to " + timeAdjust);
                    timeFailure = true;
                    nTimeFailures++;
                }

                long keyAgeMax = reply.readLongNoThrow(SESSION_KEY_AGE_MAX);
                if (keyAgeMax != -1) {
                    this.sessionKeyAgeMax = keyAgeMax;
                    if (!op.equals(OP_CREATE_SESSION)) {
                        ageFailure = true;
                        nAgeFailures++;
                        request.addString(SYSTEM, system);
                        revertToSystemKey();
                        key = systemKey;
                        request.remove(SESSION_KEY_VERSION);
                    }
                }

                String serverSession = reply.readStringNoThrow(SERVER_ID);
                if (serverSession != null) {
                    this.serverSessionId = serverSession;
                    serverSessionFailure = true;
                    nServerSessionFailures++;
                    request.addString(SERVER_ID, this.serverSessionId);
                    request.addString(SYSTEM, system);
                    revertToSystemKey();
                    key = systemKey;
                    request.remove(SESSION_KEY_VERSION);
                }

                if (timeFailure && (nTimeFailures < 8))
                    retry = true;

                if (ageFailure && (nAgeFailures < 2))
                    retry = true;

                if (serverSessionFailure && (nServerSessionFailures < 4))
                    retry = !prePing;
            }
            while (retry);

            if (timeFailure)
                throw new Ug2Exception("Failed to meet servers time demands (system time wrong?)", Ug2Exception.CONNECTIONERROR);
            if (ageFailure)
                throw new Ug2Exception("Server claims session key is too old, even when using system key (should not happen)",
                        Ug2Exception.CONNECTIONERROR);
            if (serverSessionFailure && !prePing)
                throw new Ug2Exception("Too many server session changes (server session ID reject even after "
                        + nServerSessionFailures + " retries)", Ug2Exception.CONNECTIONERROR);
        } catch (Ug2Exception e) {
            logBuf.setLength(0);
            logBuf.append(". Operation: ").append(op);
            logBuf.append(". Ug2Exception caught. Status: ").append(e.statusCode()).append(". Msg: ").append(e.getMessage());
            logBuf.append(". Thread: ").append(Thread.currentThread().getName());

            if (Ug2Exception.RPCFAILED.equals(e.statusCode()) || Ug2Exception.SERVERERROR.equals(e.statusCode())) {
                log.warn(logPrefix + logBuf.toString());
            } else if (Ug2Exception.SESSIONLOST.equals(e.statusCode()) || Ug2Exception.CONNECTIONERROR.equals(e.statusCode())) {
                log.error(logPrefix + logBuf.toString());
            } else
                log.debug(logPrefix + logBuf.toString());

            throw e;
        }

        if (respTime == 0)
            respTime = (new Date()).getTime();

        logBuf.setLength(0);
        logBuf.append(". Operation: ").append(op);

        logBuf.append(". Duration: ");
        logBuf.append((double) ((respTime - reqTime)) / 1000).append(" seconds.");

        this.lastAccessed = Instant.ofEpochMilli(reqTime);

        String protocolVersion = reply.readStringNoThrow(PROTOCOL_VERSION_TAG);
        if (protocolVersion == null)
            throw new Ug2Exception("No protocol version supplied from server (perhaps server is pre major version 3)",
                    Ug2Exception.INTERNALERROR);
        String[] protocolVersionParts = protocolVersion.split("\\.", 2);
        if (protocolVersionParts.length != 2)
            throw new Ug2Exception("Bad protocol version format received from server: " + protocolVersion,
                    Ug2Exception.INTERNALERROR);
        String protocolVersionMajor = protocolVersionParts[0];
        if (!protocolVersionMajor.equals(PROTOCOL_VERSION_MAJOR))
            throw new Ug2Exception("Server has another major protocol (" + protocolVersionMajor + ") version than client ("
                    + PROTOCOL_VERSION_MAJOR + ")", Ug2Exception.INTERNALERROR);

        status = reply.readStringNoThrow(STATUS);
        if (status == null)
            status = MISSING;

        logBuf.append(" Status: ").append(status);

        if (!status.equals(STATUS_OK)) {
            String details = reply.readStringNoThrow(DETAILS);
            Ug2Exception e;

            logBuf.append(". Msg: ");
            switch (status) {
                case STATUS_NOTPERMITTED:
                    logBuf.append("Operation not permitted. ");
                    if (details != null) {
                        logBuf.append(" Server message: ").append(details);
                        e = new Ug2Exception("Operation not permitted: " + details, status);
                    } else {
                        e = new Ug2Exception("Operation not permitted.", status);
                    }
                    break;
                case STATUS_NOTFOUND:
                    logBuf.append("Object not found.");
                    if (details != null) {
                        logBuf.append(" Server message: ").append(details);
                        e = new Ug2Exception("Object not found: " + details, status);
                    } else {
                        e = new Ug2Exception("Object not found.", status);
                    }
                    break;
                case STATUS_NOTUNIQUE:
                    logBuf.append("Object specification is not unique.");
                    if (details != null) {
                        logBuf.append(" Server message: ").append(details);
                        e = new Ug2Exception("Object specification is not unique: " + details, status);
                    } else {
                        e = new Ug2Exception("Object specification is not unique", status);
                    }
                    break;
                case STATUS_EXISTS:
                    logBuf.append("Object already exists.");
                    if (details != null) {
                        logBuf.append(" Server message: ").append(details);
                        e = new Ug2Exception("Object already exists: " + details, status);
                    } else {
                        e = new Ug2Exception("Object already exists.", status);
                    }
                    break;
                case STATUS_AUTHFAIL:
                    logBuf.append("Authentication failed.");
                    if (details != null) {
                        logBuf.append(" Server message: ").append(details);
                        e = new Ug2Exception("Authentication failed: " + details, status);
                    } else {
                        e = new Ug2Exception("Authentication failed.", status);
                    }
                    break;
                case STATUS_SERVERERROR:
                    logBuf.append("Internal server error.");
                    if (details != null) {
                        logBuf.append(" Server message: ").append(details);
                        e = new Ug2Exception("Internal server error: " + details, status);
                    } else {
                        e = new Ug2Exception("Internal server error.", status);
                    }
                    break;
                case STATUS_FAIL:
                    logBuf.append("Operation failed.");
                    if (details != null) {
                        logBuf.append(" Server message: ").append(details);
                        e = new Ug2Exception("Operation failed: " + details, status);
                    } else {
                        e = new Ug2Exception("Operation failed.", status);
                    }
                    break;
                case STATUS_WARNING:
                    logBuf.append("Operation OK but server warning issued.");
                    e = new Ug2Exception("Operation OK but server warning issued.", status, reply.readStringNoThrow(CODE));

                    break;
                case STATUS_ILLEGALDATA:
                    logBuf.append("Data provided in the rpc call was incorrect.");
                    if (details != null) {
                        logBuf.append(" Server message: ").append(details);
                        e = new Ug2Exception("Illegal data: " + details, status);
                    } else {
                        e = new Ug2Exception("Illegal data.", status);
                    }
                    break;
                default:
                    logBuf.append("Unknown status code received from server.");
                    if (details != null) {
                        logBuf.append(" Server message: ").append(details);
                        e = new Ug2Exception("Unknown status code received from server: " + details, status);
                    } else {
                        e = new Ug2Exception("Unknown status code received from server.", status);
                    }
                    break;
            }

            log.warn(logPrefix + logBuf.toString());
            throw e;
        }

        log.debug(logPrefix + logBuf.toString());

        reply.checkAuthenticator(key);

        if (request.challenge() + 1 != reply.challenge())
            throw new Ug2Exception("Server failed client challenge.");

        byte[] newSessionKey = reply.readSessionKey(key);
        if (newSessionKey != null) {
            long newSessionKeyVersion = reply.readLongNoThrow(SESSION_KEY_VERSION);
            synchronized (this) {
                if (newSessionKeyVersion > _sessionKeyVersion) {
                    _sessionKey = newSessionKey;
                    _sessionKeyVersion = newSessionKeyVersion;
                    _sessionKeyCreationTime = System.currentTimeMillis();
                }
            }
        }

        return reply;
    }

    private void setDefaultRequestIdBaseIfBaseMissing(Ug2Msg request) {
        if (request.getRequestIdBase() == null && getRequestIdBase() != null) {
            request.setRequestId(getRequestIdBase(), request.getId());
        }
    }

    /**
     * This method spins of a new keepAlive thread if no such thread is
     * already associated with this client object.
     */
    public void startKeepAlivePinging() {
        synchronized (keepAliveLock) {
            if (keepAlive == null) {
                keepAlive = new KeepAlive(keepAliveInterval, maxUnusedTime);
                keepAlive.startKeepAlive(keepAliveService);
            }
        }
        keepAlive.addClient(this);
    }

    public void stopKeepAlivePinging() {
        if (keepAlive != null) {
            keepAlive.removeClient(this);
        }
    }

    /**
     * Wrapper for setData() to use when changing values for one object and one
     * attribute.
     * 
     * @param className "user" or "group"
     * @param key attribute to use as key to identify object.
     * @param object value for key
     * @param attribute attribute to set
     * @param values array of values to set attribute to
     * @param requestId request ID for this request.
     * @throws Ug2Exception on errors.
     */
    public void setData(String className, String key, String object, String attribute, String[] values, String requestId)
            throws Ug2Exception {
        String[] objCast = new String[1];
        String[] attrCast = new String[1];
        String[][][] valCast = new String[1][1][];

        objCast[0] = object;
        attrCast[0] = attribute;
        valCast[0][0] = values;

        setData(className, key, objCast, attrCast, valCast, requestId);
    }

    /**
     * For each given object and each given attribute the values specified will
     * be the ones valid after the operation is completed. I.e. attribute values
     * not listed in the values[][][] array but assigned to the object will be
     * removed. Values not currently assigned to the object but listed in the
     * values[][][] array will be added.
     *
     * @param className "user" or "group"
     * @param key attribute to use as key to identify object.
     * @param objects array of object identifiers for key
     * @param attributes array of attributes to set
     * @param values multi-dimensional array of values to set attributes to
     * @param requestId request ID for this request.
     * @throws Ug2Exception on errors.
     */
    public void setData(String className, String key, String[] objects, String[] attributes, String[][][] values, String requestId)
            throws Ug2Exception {
        Ug2Msg request = new Ug2Msg(OP_SET_DATA, requestId);

        request.addString(CLASS, className);
        request.addString(KEY, key);
        request.addArray(objects, OBJECT);
        request.addArray(attributes, ATTRIBUTE);

        if (values != null) {
            for (int obj = 0; obj < values.length; obj++) {
                for (int attr = 0; attr < values[obj].length; attr++)
                    request.addArray(values[obj][attr], VALUE + "-" + obj + "-" + attr);
            }
        }

        doIt(request);
    }

    public Ug2DataResult getData(String className, String keyAttribute, String[] objects, String[] attributes, String requestId)
            throws Ug2Exception {
        /*
         * Marshal the request
         */

        Ug2Msg request = new Ug2Msg(OP_GET_DATA, requestId);

        request.addString(CLASS, className);
        request.addString(KEY, keyAttribute);
        request.addArray(objects, OBJECT);
        request.addArray(attributes, ATTRIBUTE);

        /*
         * Call the server
         */

        Ug2Msg reply = doIt(request);

        /*
         * Unmarshal the reply
         */

        String[] objectStatus = reply.readArrayMandatory(OBJECTSTATUS);
        String[][][] values = new String[attributes.length][][];

        for (int iAttr = 0; iAttr < attributes.length; iAttr++) {
            values[iAttr] = new String[objects.length][];

            ByteArrayInputStream bais = new ByteArrayInputStream(reply.readDataResult(iAttr));
            DataInputStream dis = new DataInputStream(bais);

            for (int iObj = 0; iObj < objects.length; iObj++) {
                if (!objectStatus[iObj].equals(STATUS_OK))
                    values[iAttr][iObj] = null;
                else {
                    try {
                        int nValues = dis.readInt();
                        values[iAttr][iObj] = new String[nValues];
                        for (int iVal = 0; iVal < nValues; iVal++)
                            values[iAttr][iObj][iVal] = dis.readUTF();
                    } catch (IOException e) {
                        throw new Ug2Exception("Failed to read compressed data result", e);
                    }
                }
            }
        }

        return new Ug2DataResult(objects, attributes, objectStatus, values, true);
    }

    public String[][] getData(String className, String keyAttribute, String[] objects, String attribute, String requestId)
            throws Ug2Exception {
        Ug2DataResult data = this.getData(className, keyAttribute, objects, new String[]{attribute}, requestId);

        int n = objects.length;
        String[][] result = new String[n][];

        for (int i = 0; i < n; i++) {
            if (!data.status(i).equals("OK"))
                throw new Ug2Exception("Can't retrieve data for " + className + "/" + keyAttribute + "=" + objects[i], data.status(0));

            result[i] = data.values(i, 0);
        }

        return result;
    }

    /**
     * This method uses the values provided for the lookupAttribute in order to
     * find objects in the UG database. The object references are returned
     * expressed as values of the returnKeyAttribute, which MUST be a true key
     * attribute (i.e. unique, single valued and always non-null).
     *
     * @param className          Object references are wanted for this class
     *                           (user|group|system)
     * @param lookupAttribute    The search values provided are for this object attribute
     * @param lookupValues       Values of the looupAttribute to return objects for.
     * @param returnKeyAttribute The object references returned are expressed in values of this
     *                           attribute NOTE: This attribute MUST be a true key, i.e.
     *                           unique, single valued and not-null!
     * @param requestId          Value to tag this request with. May be null.
     * @return The object arrays returned (inner array) are mapped to the values
     * that they matched through the outer array, in which the index
     * matches the index of the corresponding value in the lookupValues
     * array.
     * @throws Ug2Exception on errors.
     */
    public String[][] findObjects(String className, String lookupAttribute, String[] lookupValues, String returnKeyAttribute,
                                  String requestId) throws Ug2Exception {
        Ug2Msg request = new Ug2Msg(OP_FIND_OBJECTS, requestId);

        request.addString(CLASS, className);
        request.addString(LOOKUPATTR, lookupAttribute);
        request.addString(KEY, returnKeyAttribute);
        request.addArray(lookupValues, LOOKUPVALUE);

        Ug2Msg reply = doIt(request);
        String[][] retval = null;

        /*
         * Now we parse the received reply. Each provided lookup value in the
         * request generates an array of object id Strings in the reply.
         */

        StringBuilder objArrTag = new StringBuilder(OBJECT).append("-");
        int tagLen = objArrTag.length();
        int numObjArr = reply.readIntMandatory(NUMLOOKUPVALS);
        int i;

        if (numObjArr >= 0) {
            retval = new String[numObjArr][];
            for (i = 0; i < numObjArr; i++, objArrTag.setLength(tagLen)) {
                objArrTag.append(i);
                retval[i] = reply.readArrayMandatory(objArrTag.toString());
            }
        }

        return retval;
    }

    
    /**
     * This method uses the values provided for the lookupAttribute in order to
     * find objects in the UG database. The object references are returned
     * expressed as values of the returnKeyAttribute, which MUST be a true key
     * attribute (i.e. unique, single valued and always non-null).
     *
     * @param className          Object class (user|group|system)
     * @param lookupAttribute    The search value provided are for this object attribute
     * @param lookupValue        Value of the looupAttribute to return objects for.
     * @param returnKeyAttribute The object references returned are expressed in values of this
     *                           attribute NOTE: This attribute MUST be a true key, i.e.
     *                           unique, single valued and not-null!
     * @param requestId          Value to tag this request with. May be null.
     * @return array of key attributes of objects matching lookup attribute/value pair. 
     * @throws Ug2Exception on errors.
     */
    public String[] findObjects(String className, String lookupAttribute, String lookupValue, String returnKeyAttribute,
    		String requestId) throws Ug2Exception {
    	String[][] result = findObjects(className, lookupAttribute, new String[]{lookupValue}, returnKeyAttribute, requestId);
    	return result[0];
    }

    /**
     * This method uses the value provided for the lookupAttribute in order to
     * find a unique object in the UG database. The object references are returned
     * expressed as values of the returnKeyAttribute, which MUST be a true key
     * attribute (i.e. unique, single valued and always non-null).
     *
     * @param className          Object class (user|group|system)
     * @param lookupAttribute    The search value provided are for this object attribute
     * @param lookupValue        Value of the looupAttribute to return object for.
     * @param returnKeyAttribute The object references returned are expressed in values of this
     *                           attribute NOTE: This attribute MUST be a true key, i.e.
     *                           unique, single valued and not-null!
     * @param requestId          Value to tag this request with. May be null.
     * @return the key attribute of the object matching lookup attribute/value pair. 
     * @throws Ug2Exception on errors, including finding more than one matching object. 
     */
    public String findObject(String className, String lookupAttribute, String lookupValue, String returnKeyAttribute,
    		String requestId) throws Ug2Exception {
    	String[][] result = findObjects(className, lookupAttribute, new String[]{lookupValue}, returnKeyAttribute, requestId);
    	if (result[0].length == 0) {
    		return null;
    	} else if (result[0].length != 1) {
    		throw new Ug2Exception("The lookup value is not unique, there are " + result[0].length + " " + className + "objects "
    				+ "with the value \"" + lookupValue + "\" for attribute " + lookupAttribute);
    	} else {
    		return result[0][0];
    	}
    }    
    
    public List groupsMatching(String value, String requestId) throws Ug2Exception {
        Ug2Msg request = new Ug2Msg(Ug2Protocol.OP_OBJECTS_MATCHING, requestId);
        request.addString(Ug2Protocol.CLASS, Ug2Protocol.GROUP);
        request.addString(Ug2Protocol.ATTRIBUTE, Ug2Protocol.UG1NAME);
        request.addString(Ug2Protocol.VALUE, value);
        Ug2Msg reply = doIt(request);
        return reply.readList(Ug2Protocol.OBJECT);
    }

    public String[] objectsMatching(String className, String attribute, String value, String requestId) throws Ug2Exception {
        Ug2Msg request = new Ug2Msg(OP_OBJECTS_MATCHING, requestId);

        request.addString(CLASS, className);
        request.addString(ATTRIBUTE, attribute);
        request.addString(VALUE, value);

        Ug2Msg reply = doIt(request);

        return reply.readArrayMandatory(OBJECT);
    }

    public Ug2Schema getSchema(String requestId) throws Ug2Exception {
        Ug2Msg request = new Ug2Msg(OP_GET_SCHEMA, requestId);
        Ug2Msg reply = doIt(request);
        return reply.readSchema();
    }

    /**
     * Ping server
     * @param requestId Value to tag this request with. May be null.
     * @throws Ug2Exception on errors.
     */
    public void ping(String requestId) throws Ug2Exception {
        Ug2Msg request = new Ug2Msg(OP_PING, requestId);
        doIt(request).toMap();
    }

    void sendKeepAlive() {
        try {
            ping("KeepAlive-" + sessionId);
        } catch (Throwable e) {
            log.warn("Keep-alive ping failed for " + sessionId + ", will stop pinging", e);
            stopKeepAlivePinging();
        }
    }

    public void prePing(String system, String password, String requestId) throws Ug2Exception {
        Ug2Msg request = Ug2Msg.makeEmptyMsg();
        request.addStringNoCheck(OPERATION, OP_PRE_PING);
        request.addStringNoCheck(REQUESTID, requestId + "_prePing");
        if (system != null)
            request.addStringNoCheck(SYSTEM, system);

        try {
            doIt(request);
            return;
        } catch (Ug2Exception e) {
            String code = e.statusCode();
            if (code == null || !code.equals(STATUS_NOTFOUND))
                throw e;

            String msg = e.getMessage();
            if (msg == null || !msg.endsWith("No system key, provide system password!"))
                throw e;

            if (system == null || password == null)
                throw e;
        }

        request.addStringNoCheck(PASSWORD, password);
        doIt(request);
    }

    public long currentVersion(String requestId)
            throws Ug2Exception {
        Ug2Msg request = new Ug2Msg(OP_CURRENT_VERSION, requestId);
        Ug2Msg reply = doIt(request);
        return reply.readLongMandatory(VERSION);
    }

    /**
     * Terminates the session with the Ug2Server in a controlled way by killing
     * off local maintenance threads and by removing the session and ALL its
     * token serie associations from the server.
     *
     * @param requestId Id string provided by the calling client. Provides tracability
     *                  of requests from the client all the way through the server. If
     *                  this parameter is non-null in the client side of the API, the
     *                  value is included in the requestId, visible in logs for
     *                  tracing purposes. Truncated to Ug2Msg.MAX_CLIENT_REQ_ID_LEN.
     *                  Ignored if null.
     */
    public void terminateSession(String requestId) {
        if (state.get() == NORMAL) {
            log.info("Terminating session " + sessionId);
            Ug2Msg request = new Ug2Msg(OP_TERMINATE_SESSION, requestId);
            try {
                doIt(request);
            } catch (Throwable e) {
                log.warn("Ug2Client: terminateSession (): ReqId: " + request.getId() + ". SessId: " + sessionId()
                        + ". Caught " + e.getClass().getName() + " during termination.", e);
            }
            stopKeepAlivePinging();
            state.set(TERMINATED);
        }
    }

    public String[] allObjectsHaving(String className, String attribute, String requestId) throws Ug2Exception {
        Ug2Msg request = new Ug2Msg(OP_ALL_OBJECTS_HAVING, requestId);

        request.addString(CLASS, className);
        request.addString(ATTRIBUTE, attribute);

        Ug2Msg reply = doIt(request);

        return reply.readArrayMandatory(OBJECT);
    }

    public List allUsers(String requestId) throws Ug2Exception {
        Ug2Msg request = new Ug2Msg(Ug2Protocol.OP_ALL_OBJECTS_HAVING, requestId);
        request.addString(Ug2Protocol.CLASS, Ug2Protocol.USER);
        request.addString(Ug2Protocol.ATTRIBUTE, Ug2Protocol.KTHID);
        Ug2Msg reply = doIt(request);
        return reply.readList(Ug2Protocol.OBJECT);
    }

    public String[][] accumulateGroupData(String groupKthid, String[] attributes, boolean excludeDirect,
                                          String requestId) throws Ug2Exception {
        Ug2Msg request = new Ug2Msg(OP_ACCUMULATE_GROUP_DATA, requestId);
        request.addString(KTHID, groupKthid);
        request.addArray(attributes, ATTRIBUTE);
        request.addBool(EXCLUDE_DIRECT, excludeDirect);

        Ug2Msg reply = doIt(request);

        String[][] result = new String[attributes.length][];
        for (int i = 0; i < attributes.length; i++)
            result[i] = reply.readArrayMandatory(VALUE + "-" + i);
        return result;
    }

    public String[] accumulateGroupData(String groupKthid, String attribute, String requestId) throws Ug2Exception {
        return accumulateGroupData(groupKthid, new String[]{attribute}, false, requestId)[0];
    }

    public String[] membership(String ug2class, String kthid, boolean excludeDirect, boolean excludeIndirect,
                               String requestId) throws Ug2Exception {
        Ug2Msg request = new Ug2Msg(OP_MEMBERSHIP, requestId);
        request.addString(CLASS, ug2class);
        request.addString(KTHID, kthid);
        request.addBool(EXCLUDE_DIRECT, excludeDirect);
        request.addBool(EXCLUDE_INDIRECT, excludeIndirect);

        Ug2Msg reply = doIt(request);

        return reply.readArrayMandatory(GROUP);
    }

    public Ug2ChangeLogEntry[] getChangeLogEntries(long maxEntries, long minVersion, long maxVersion, long minTime,
                                                   long maxTime, String ug2class, String kthid, String attribute,
                                                   String value, String operator, String facility, String sessionId,
                                                   String logRequestId, String requestId) throws Ug2Exception {
        Ug2Msg request = new Ug2Msg(OP_GET_CHANGE_LOG_ENTRIES, requestId);

        request.addLong(LENGTH, maxEntries);
        request.addLong(MIN_VERSION, minVersion);
        request.addLong(MAX_VERSION, maxVersion);
        request.addLong(MIN_TIME, minTime);
        request.addLong(MAX_TIME, maxTime);
        request.addString(CHANGE_LOG_CLASS, ug2class);
        request.addString(CHANGE_LOG_KTHID, kthid);
        request.addString(CHANGE_LOG_ATTRIBUTE, attribute);
        request.addString(CHANGE_LOG_VALUE, value);
        request.addString(CHANGE_LOG_OPERATOR, operator);
        request.addString(CHANGE_LOG_FACILITY, facility);
        request.addString(CHANGE_LOG_SESSION, sessionId);
        request.addString(CHANGE_LOG_REQUEST, logRequestId);

        Ug2Msg reply = doIt(request);

        return reply.readChangeLogEntryArray(VALUE);
    }

    public void setState(byte state) {
        this.state.set(state);
    }

    /**
     * ************************************************************************
     * Private utility method section. *
     * ************************************************************************
     */

    private void createSession(String system, String password, String requestId, Transport conn) throws Ug2Exception {
        String logPrefix = "Ug2Client: createSession (): ";

        if (system == null) {
            throw new Ug2Exception(logPrefix + "No system principal name supplied.");
        }

        if (password == null) {
            throw new Ug2Exception(logPrefix + "No system principal credentials supplied.");
        }

        this.channel = conn;

        this._sessionKey = null;
        this.systemKey = stringToKey(password);
        this.system = system;

        prePing(system, password, requestId);

        Ug2Msg request = new Ug2Msg(OP_CREATE_SESSION, requestId);
        request.addString(SYSTEM, system);
        Ug2Msg reply = doIt(request);

        sessionId = reply.readLongMandatory(SESSIONID);
        state.set(NORMAL);
    }

    public static byte[] stringToKey(String password) throws Ug2Exception {
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");
            md.update(password.getBytes(StandardCharsets.UTF_8));
            return md.digest();
        } catch (NoSuchAlgorithmException e) {
            throw new Ug2Exception("Failed to generate message digest (MD5)", e);
        }
    }

    /**
     * Returns the session id at the server side that this client is associated
     * to.
     * @return sessionId or "unknown".
     */
    public String sessionId() {
        return sessionId == null ? "unknown" : sessionId.toString();
    }

    private synchronized void revertToSystemKey() {
        _sessionKey = null;
        _sessionKeyVersion = -1;
        _sessionKeyCreationTime = -1;
    }

    private static final int taMax = 16;
    private int taN = 0;
    private final long taData[] = new long[taMax];
    private int taNext = 0;

    private synchronized void updateServerTimeAdjustment(long timeOffset) {
        taData[taNext] = timeOffset;
        taNext = (taNext + 1) % taMax;
        if (taN < taMax)
            taN++;

        long sum = 0;
        for (int i = 0; i < taN; i++)
            sum += taData[i];

        serverTimeAdjust = sum / taN;
        log.info("Offset " + timeOffset + " received, changing global adjustment to " + serverTimeAdjust);
    }

    public String getRequestIdBase() {
        return requestIdBase;
    }

    public void setRequestIdBase(String requestId) {
        this.requestIdBase = requestId;
    }

    public List getUsers(List kthids, List attributeNames, String requestId) throws Ug2Exception {
        Ug2Msg request = new Ug2Msg(Ug2Protocol.OP_GET_DATA, requestId);

        request.addString(Ug2Protocol.CLASS, USER);
        request.addString(Ug2Protocol.KEY, KTHID);
        request.addArray(kthids.toArray(new String[kthids.size()]), Ug2Protocol.OBJECT);
        request.addArray(attributeNames.toArray(new String[attributeNames.size()]), Ug2Protocol.ATTRIBUTE);

        Ug2Msg reply = doIt(request);
        String[] objectStatus = reply.readArrayMandatory(OBJECTSTATUS);

        List users = new ArrayList<>(kthids.size());
        users.addAll(kthids.stream().map(Ug2User::new).collect(Collectors.toList()));
        int i = 0;
        for (String attributeName : attributeNames) {
            ByteArrayInputStream bais = new ByteArrayInputStream(reply.readDataResult(i));
            DataInputStream dis = new DataInputStream(bais);

            for (int j = 0; j < kthids.size(); j++) {
                if (objectStatus[j].equals(STATUS_OK)) {
                    try {
                        int nValues = dis.readInt();
                        List values = new ArrayList<>(nValues);
                        for (int k = 0; k < nValues; k++) {
                            values.add(dis.readUTF());
                        }
                        users.get(j).addAttribute(attributeName, values);
                    } catch (IOException e) {
                        throw new Ug2Exception("Failed to read compressed data result", e);
                    }
                } else {
                    users.get(j).addEmptyAttribute(attributeName);
                }
            }
            i++;
        }
        return users;
    }

    public List getGroups(List kthids, List attributeNames, String requestId) throws Ug2Exception {
        Ug2Msg request = new Ug2Msg(Ug2Protocol.OP_GET_DATA, requestId);
        request.addString(Ug2Protocol.CLASS, GROUP);
        request.addString(Ug2Protocol.KEY, KTHID);
        request.addArray(kthids.toArray(new String[kthids.size()]), Ug2Protocol.OBJECT);
        request.addArray(attributeNames.toArray(new String[attributeNames.size()]), Ug2Protocol.ATTRIBUTE);
        Ug2Msg reply = doIt(request);
        String[] objectStatus = reply.readArrayMandatory(OBJECTSTATUS);
        List groups = new ArrayList<>(kthids.size());
        groups.addAll(kthids.stream().map(Ug2Group::new).collect(Collectors.toList()));
        int i = 0;
        for (String attributeName : attributeNames) {
            ByteArrayInputStream bais = new ByteArrayInputStream(reply.readDataResult(i));
            DataInputStream dis = new DataInputStream(bais);
            for (int j = 0; j < kthids.size(); j++) {
                if (objectStatus[j].equals(STATUS_OK)) {
                    try {
                        int nValues = dis.readInt();
                        List values = new ArrayList<>(nValues);
                        for (int k = 0; k < nValues; k++) {
                            values.add(dis.readUTF());
                        }
                        groups.get(j).addAttribute(attributeName, values);
                    } catch (IOException e) {
                        throw new Ug2Exception("Failed to read compressed data result", e);
                    }
                } else {
                    groups.get(j).addEmptyAttribute(attributeName);
                }
            }
            i++;
        }
        return groups;
    }

    public Instant getLastAccessed() {
        return lastAccessed;
    }

    public void closeSession(String requestId) {
        if (state.get() == NORMAL) {
            Ug2Msg request = new Ug2Msg(OP_TERMINATE_SESSION, requestId);
            try {
                doIt(request);
                state.set(TERMINATED);
            } catch (Throwable e) {
                log.warn("Failed to terminate session " + sessionId() + ", request " + request.getId() + " failed", e);
            }
        }

    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy