dbmedialab/reader-critics

View on GitHub
src/app/main/vote.ts

Summary

Maintainability
A
0 mins
Test Coverage
//
// LESERKRITIKK v2 (aka Reader Critics)
// Copyright (C) 2017 DB Medialab/Aller Media AS, Oslo, Norway
// https://github.com/dbmedialab/reader-critics/
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU General Public License as published by the Free Software
// Foundation, either version 3 of the License, or (at your option) any later
// version.
//
// 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <http://www.gnu.org/licenses/>.
//

import {
    createRedisConnection,
    dbMessageQueue,
} from 'app/db';

import * as IORedis from 'ioredis';
import * as app from 'app/util/applib';

const log = app.createLog('vote');

const lockKey = 'vote-lock:masterID';

// These time constants are in seconds
const startupVariance = 2;  // Variance is applied as a random time span
const rewriteVariance = 1;
const lockTimeToLive = 5;  // Time until the lock key in Redis expires

let redis : IORedis.Redis;
let isMaster : boolean = false;

export function initVote() : Promise <void> {
    const startupDelay = Math.round(Math.random() * (startupVariance * 1000));
    log('Initialising master vote (%dms)', startupDelay);
    redis = createRedisConnection(dbMessageQueue);

    setTimeout(() => {
        lockHandler();

        // The rewrite delay is shorter than the key TTL to ensure that the key
        // gets refreshed by the current master in Redis before it expires.
        const rewriteDelay = (1000 * lockTimeToLive)
            - Math.round(Math.random() * (rewriteVariance * 1000));

        log('Starting vote lock rewrite loop (%dms)', rewriteDelay);

        setInterval(() => lockHandler(), rewriteDelay);
    }, startupDelay);

    return Promise.resolve();
}

const getLock = () => redis.get(lockKey);

const setLock = () => redis.set(lockKey, app.nodeID, 'ex', lockTimeToLive);

function lockHandler() {
    getLock().then((value) => {
        // Application start and the lock doesn't exist yet or the old master node
        // has gone away and the lock was deleted by Redis after expiring its TTL
        if (value === null) {
            setLock();
            isMaster = true;
            log('Acquired master');
        }
        // Lock exists and contains the ID of this node; rewrite it to extend TTL
        else if (value === app.nodeID) {
            setLock();
            isMaster = true;
        }
        // Every other case: this is not the master node
        else {
            isMaster = false;
        }
    });
}

/**
 * This wrapper takes a cron function to execute. The provided function will
 * only be called if the local node is the current master in the cluster, thus
 * ensuring that jobs only get triggered once in a distributed deployment.
 *
 * The wrapper retries for 2*lockTimeToLive and checks if the master flag turns
 * "true" on this node. This timeout ensures that jobs get executed (with a
 * slight delay) even if the current master has just gone away. Especially
 * helpful with jobs that f.ex. are only triggered once per day.
 *
 * If this node is the current master, the cron function will be executed
 * immediately.
 */
export function wrapCronFn(cronFn : Function) {
    const executeCronFn = () => {
        try {
            cronFn();
        }
        catch (error) {
            app.yell(error);
        }
    };

    // If this is the master node, shoot the cron function and instantly leave
    if (isMaster) {
        executeCronFn();
        return;
    }

    // If this is not master, wait for 2*lockTimeToLive if this node acquires it
    const startTime = Math.round(Date.now() / 1000);

    const intervalID = setInterval(() => {
        const nowTime = Math.round(Date.now() / 1000);

        if (nowTime >= startTime + (lockTimeToLive * 2)) {
            clearInterval(intervalID);  // Timeout, not trying anymore
        }
        else if (isMaster) {  // Retrying
            clearInterval(intervalID);
            executeCronFn();
        }
    }, 1000);
}