"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const constants_1 = require("../../constants");
const types_1 = require("@deepstream/types");
const events_1 = require("events");
/**
 * This class provides a generic mechanism that allows to maintain
 * a distributed state amongst the nodes of a cluster. The state is an
 * array of unique strings in arbitrary order.
 *
 * Whenever a string is added by any node within the cluster for the first time,
 * an 'add' event is emitted. Whenever its removed by the last node within the cluster,
 * a 'remove' event is emitted.
 */
class DistributedStateRegistry {
    /**
     * Initializes the DistributedStateRegistry and subscribes to the provided cluster topic
     */
    constructor(topic, stateOptions, services, config) {
        this.topic = topic;
        this.stateOptions = stateOptions;
        this.services = services;
        this.config = config;
        this.isReady = false;
        this.data = new Map();
        this.reconciliationTimeouts = new Map();
        this.checkSumTimeouts = new Map();
        this.fullStateSent = false;
        this.initialServers = new Set();
        this.emitter = new events_1.EventEmitter();
        this.logger = this.services.logger.getNameSpace('DISTRIBUTED_STATE_REGISTRY');
        this.resetFullStateSent = this.resetFullStateSent.bind(this);
        this.services.clusterNode.subscribe(constants_1.TOPIC.STATE_REGISTRY, this.processIncomingMessage.bind(this));
        const serverNames = this.services.clusterRegistry.getAll();
        this.initialServers = new Set(serverNames);
        if (this.initialServers.size === 0) {
            this.isReady = true;
            this.emitter.emit('ready');
        }
        this.initialServers.forEach((serverName) => {
            if (serverName !== this.config.serverName) {
                this.onServerAdded(serverName);
            }
        });
        this.services.clusterRegistry.onServerAdded(this.onServerAdded.bind(this));
        this.services.clusterRegistry.onServerRemoved(this.onServerRemoved.bind(this));
    }
    async whenReady() {
        if (!this.isReady) {
            await new Promise((resolve) => this.emitter.once('ready', resolve));
        }
    }
    onAdd(callback) {
        this.emitter.on('add', callback);
    }
    onRemove(callback) {
        this.emitter.on('remove', callback);
    }
    /**
     * Checks if a given entry exists within the registry
     */
    has(name) {
        return this.data.has(name);
    }
    /**
     * Add a name/entry to the registry. If the entry doesn't exist yet,
     * this will notify the other nodes within the cluster
     */
    add(name) {
        const data = this.data.get(name);
        if (!data || !data.nodes.has(this.config.serverName)) {
            this.addToServer(name, this.config.serverName);
            this.sendMessage(name, constants_1.STATE_ACTION.ADD);
        }
        else {
            data.localCount++;
        }
    }
    /**
     * Removes a name/entry from the registry. If the entry doesn't exist,
     * this will exit silently
     */
    remove(name) {
        const data = this.data.get(name);
        if (data) {
            data.localCount--;
            if (data.localCount === 0) {
                this.removeFromServer(name, this.config.serverName);
                this.sendMessage(name, constants_1.STATE_ACTION.REMOVE);
            }
        }
    }
    removeAll(serverName) {
        throw new Error('Method not implemented.');
    }
    /**
     * Informs the distributed state registry a server has been added to the cluster
     */
    onServerAdded(serverName) {
        this._requestFullState(serverName);
    }
    /**
     * Removes all entries for a given serverName. This is intended to be called
     * whenever a node is removed from the cluster
     */
    onServerRemoved(serverName) {
        for (const [name, value] of this.data) {
            if (value.nodes.has(serverName)) {
                this.removeFromServer(name, serverName);
            }
        }
    }
    /**
     * Returns all the servers that hold a given state
     */
    getAllServers(name) {
        const data = this.data.get(name);
        if (data) {
            return [...data.nodes.keys()];
        }
        return [];
    }
    /**
     * Returns all currently registered entries
     */
    getAll(serverName) {
        if (!serverName) {
            return [...this.data.keys()];
        }
        const entries = [];
        for (const [name, value] of this.data) {
            if (value.nodes.has(serverName)) {
                entries.push(name);
            }
        }
        return entries;
    }
    /**
     * Removes an entry for a given serverName. If the serverName
     * was the last node that held the entry, the entire entry will
     * be removed and a `remove` event will be emitted
     */
    removeFromServer(name, serverName) {
        const data = this.data.get(name);
        if (!data) {
            return;
        }
        data.nodes.delete(serverName);
        const exists = data.nodes.size !== 0;
        if (exists === false) {
            this.data.delete(name);
            this.emitter.emit('remove', name);
        }
        this.emitter.emit('server-removed', name, serverName);
    }
    /**
     * Adds a new entry to this registry, either as a result of a remote or
     * a local addition. Will emit an `add` event if the entry wasn't present before
     */
    addToServer(name, serverName) {
        let data = this.data.get(name);
        if (!data) {
            data = {
                localCount: 1,
                nodes: new Set(),
                checkSum: this.createCheckSum(name)
            };
            this.data.set(name, data);
            this.emitter.emit('add', name);
        }
        data.nodes.add(serverName);
        this.emitter.emit('server-added', name, serverName);
    }
    /**
     * Generic messaging function for add and remove messages
     */
    sendMessage(name, action) {
        this.services.clusterNode.send({
            topic: constants_1.TOPIC.STATE_REGISTRY,
            registryTopic: this.topic,
            action,
            name
        });
        this.getCheckSumTotal(this.config.serverName, (checksum) => this.services.clusterNode.send({
            topic: constants_1.TOPIC.STATE_REGISTRY,
            registryTopic: this.topic,
            action: constants_1.STATE_ACTION.CHECKSUM,
            checksum
        }));
    }
    /**
     * This method calculates the total checkSum for all local entries of
     * a given serverName
     */
    getCheckSumTotal(serverName, callback) {
        const callbacks = this.checkSumTimeouts.get(serverName);
        if (callbacks) {
            callbacks.push(callback);
        }
        else {
            this.checkSumTimeouts.set(serverName, [callback]);
            setTimeout(() => {
                let totalCheckSum = 0;
                for (const [, value] of this.data) {
                    if (value.nodes.has(serverName)) {
                        totalCheckSum += value.checkSum;
                    }
                }
                this.checkSumTimeouts.get(serverName).forEach((cb) => cb(totalCheckSum));
                this.checkSumTimeouts.delete(serverName);
            }, this.stateOptions.checkSumBuffer);
        }
    }
    /**
     * Calculates a simple checkSum for a given name. This is done up-front and cached
     * to increase performance for local add and remove operations. Arguably this is a generic
     * method and might be moved to the utils class if we find another usecase for it.
     */
    createCheckSum(name) {
        let checkSum = 0;
        let i;
        for (i = 0; i < name.length; i++) {
            // tslint:disable-next-line:no-bitwise
            checkSum = ((checkSum << 5) - checkSum) + name.charCodeAt(i); // eslint-disable-line
        }
        return checkSum;
    }
    /**
     * Checks a remote checkSum for a given serverName against the
     * actual checksum for all local entries for the given name.
     *
     * - If the checksums match, it removes all possibly pending
     *   reconciliationTimeouts
     *
     * - If the checksums don't match, it schedules a reconciliation request. If
     *   another message from the remote server arrives before the reconciliation request
     *   is send, it will be cancelled.
     */
    verifyCheckSum(serverName, remoteCheckSum) {
        this.getCheckSumTotal(serverName, (checksum) => {
            if (checksum !== remoteCheckSum) {
                this.reconciliationTimeouts.set(serverName, setTimeout(this._requestFullState.bind(this, serverName), this.stateOptions.stateReconciliationTimeout));
                return;
            }
            const timeout = this.reconciliationTimeouts.get(serverName);
            if (timeout) {
                clearTimeout(timeout);
                this.reconciliationTimeouts.delete(serverName);
            }
        });
    }
    /**
     * Sends a reconciliation request for a server with a given name (technically, its send to
     * every node within the cluster, but will be ignored by all but the one with a matching name)
     *
     * The matching node will respond with a DISTRIBUTED_STATE_FULL_STATE message
     */
    _requestFullState(serverName) {
        this.services.clusterNode.sendDirect(serverName, {
            topic: constants_1.TOPIC.STATE_REGISTRY,
            registryTopic: this.topic,
            action: constants_1.STATE_ACTION.REQUEST_FULL_STATE,
        });
    }
    /**
     * Creates a full state message containing an array of all local entries that
     * will be used to reconcile compromised states as well as provide the full state
     * for new nodes that joined the cluster
     *
     * When a state gets compromised, more than one remote registry might request a full state update.
     * This method will  schedule a timeout in which no additional full state messages are sent to
     * make sure only a single full state message is sent in reply.
     */
    sendFullState(serverName) {
        const localState = [];
        for (const [name, value] of this.data) {
            if (value.nodes.has(this.config.serverName)) {
                localState.push(name);
            }
        }
        this.services.clusterNode.sendDirect(serverName, {
            topic: constants_1.TOPIC.STATE_REGISTRY,
            registryTopic: this.topic,
            action: constants_1.STATE_ACTION.FULL_STATE,
            fullState: localState
        });
        this.fullStateSent = true;
        setTimeout(this.resetFullStateSent, this.stateOptions.stateReconciliationTimeout);
    }
    /**
     * This will apply the data from an incoming full state message. Entries that are not within
     * the incoming array will be removed for that node from the local registry and new entries will
     * be added.
     */
    applyFullState(serverName, names) {
        const namesMap = {};
        for (let i = 0; i < names.length; i++) {
            namesMap[names[i]] = true;
        }
        Object.keys(this.data).forEach((name) => {
            // please note: only checking if the name exists is sufficient as the registry will just
            // set node[serverName] to false if the entry exists, but not for the remote server.
            if (!namesMap[name]) {
                this.removeFromServer(name, serverName);
            }
        });
        names.forEach((name) => this.addToServer(name, serverName));
        this.initialServers.delete(serverName);
        if (this.initialServers.size === 0) {
            this.isReady = true;
            this.emitter.emit('ready');
        }
    }
    /**
     * Will be called after a full state message has been sent and
     * stateReconciliationTimeout has passed. This will allow further reconciliation
     * messages to be sent again.
     */
    resetFullStateSent() {
        this.fullStateSent = false;
    }
    /**
     * This is the main routing point for messages coming in from
     * the message connector.
     */
    processIncomingMessage(message, serverName) {
        if (message.registryTopic !== this.topic) {
            return;
        }
        if (message.action === constants_1.STATE_ACTION.ADD) {
            this.addToServer(message.name, serverName);
            return;
        }
        if (message.action === constants_1.STATE_ACTION.REMOVE) {
            this.removeFromServer(message.name, serverName);
            return;
        }
        if (message.action === constants_1.STATE_ACTION.REQUEST_FULL_STATE) {
            if (!message.data || this.fullStateSent === false) {
                this.sendFullState(serverName);
            }
            else {
                this.logger.error(types_1.EVENT.ERROR, `Ignoring a request for full state from ${serverName}`);
            }
            return;
        }
        if (message.action === constants_1.STATE_ACTION.FULL_STATE) {
            this.applyFullState(serverName, message.fullState);
        }
        if (message.action === constants_1.STATE_ACTION.CHECKSUM) {
            this.verifyCheckSum(serverName, message.checksum);
        }
    }
}
exports.DistributedStateRegistry = DistributedStateRegistry;
//# sourceMappingURL=distributed-state-registry.js.map