db/config.js

/*
 *  Author: Vlad Seryakov vseryakov@gmail.com
 *  backendjs 2018
 */

const app = require(__dirname + '/../app');
const db = require(__dirname + '/../db');
const logger = require(__dirname + '/../logger');
const lib = require(__dirname + '/../lib');

/**
 * Load configuration from the config database, must be configured with `db-config` pointing to the database pool where bk_config table contains
 * configuration parameters.
 *
 * The priority of the parameters goes from the most broad to the most specific, most specific always wins, this allows
 * for very flexible configuration policies defined by the app or place where instances running and separated by the run mode.
 * See `db.getConfigTypes`` for more details.
 *
 * The options takes the following properties:
 *  - force - if true then force to refresh and reopen all db pools
 *  - table - a table where to read the config parameters, default is bk_config
 *
 * Do not create/upgrade tables and indexes when reloading the config, this is to
 * avoid situations when maintenance is being done and any process reloading the config may
 * create indexes/columns which are not missing but being provisioned or changed.
 * @memberOf module:db
 * @method initConfig
 */

db.initConfig = function(options, callback)
{
    if (typeof options == "function") callback = options, options = null;

    // Refresh from time to time with new or modified parameters, randomize a little to spread across all servers.
    var interval = db.configMap.interval > 0 ? db.configMap.interval * 60000 + lib.randomShort() : 0;
    lib.deferInterval(this, interval, "config", this.initConfig.bind(this, interval ? options : null));

    if (this.none) return lib.tryCall(callback);

    this.getConfig(options, (err, rows) => {
        if (err || !rows?.length) {
            return lib.tryCall(callback, err, []);
        }

        this._configTime = Date.now();

        var argv = rows.reduce((l, x) => {
            l.push('-' + x.name);
            if (x.value) l.push(x.value);
            return l;
        }, []);

        app.parseArgs(argv, 0, "db");

        // Create or reconfigure db pools if needed
        db.init(options, callback);
    });
}

/**
 * Return all config records for the given instance, the result will be sorted most relevant at the top
 * @memberOf module:db
 * @method getConfig
 */
db.getConfig = function(options, callback)
{
    if (typeof options == "function") callback = options, options = null;

    var pool = options?.pool || this.config;
    if (!pool) return lib.tryCall(callback);

    var now = Date.now();
    var query = {
        status: "ok",
        type: this.configTypes(options),
        $or: { stime: null, stime_$: 0, stime_$$le: now },
        $$or: { etime: null, etime_$: 0, etime_$$ge: now },
        mtime_$gt: options?.mtime,
        name: options?.name,
    };
    var opts = {
        pool,
        count: this.configMap.count,
    };
    var table = options?.table || "bk_config";

    this.select(table, query, opts, (err, rows, info) => {
        // Sort inside to be consistent across the databases
        if (!err) {
            var ver = lib.toVersion(app.appVersion);
            rows = rows.filter((x) => (lib.validVersion(ver, x.version) && (!x.stime || x.stime <= now) && (!x.etime || x.etime >= now))).
                        sort((a,b) => (query.type.indexOf(a.type) - query.type.indexOf(b.type) ||
                             lib.toNumber(a.sort) - lib.toNumber(b.sort) ||
                             a.ctime - b.ctime));
        }
        logger.debug("getConfig:", app.role, query, rows?.length, "rows", info.elapsed, "ms");
        lib.tryCall(callback, err, rows);
    });
}

/**
 * Build a list of all config types we want to retrieve, based on the `db-config-map` parameters that defines which fields to use for config types.
 *
 * - for each `top` item it create a mix of all main items below
 * - for each `main` item it creates a mix of all `other`` items
 *
 *  top... -> each of top-main... -> each of top-main-other...
 *
 * Most common config parameters: runMode, role, roles, tag, region
 *
 * @example top is runMode(prod), main is role(shell),tag(local), other is region(none)
 *  - prod
 *  - prod-shell
 *  - prod-shell-none
 *  - prod-local
 *  - prod-local-none
 * @memberOf module:db
 * @method configTypes
 */
db.configTypes = function(options)
{
    var types = new Set();

    var top = lib.split(this.configMap.top, null, { unique: 1 }).flatMap((x) => (app[x] || app.instance[x])).filter(x => x);
    var main = lib.split(this.configMap.main, null, { unique: 1 }).flatMap((x) => (app[x] || app.instance[x])).filter(x => x);
    var other = lib.split(this.configMap.other, null, { unique: 1 }).flatMap((x) => (app[x] || app.instance[x])).filter(x => x);

    logger.debug("configTypes:", app.role, "T:", top, "M:", main, "O:", other);

    for (const t of top) {
        const field = [t];
        types.add(t);

        for (const m of main) {
            field.push(m);
            types.add(field.join("-"));
            for (const o of other) {
                types.add([...field, o].join("-"));
            }
            field.pop();
        }
    }
    return Array.from(types);
}

/**
 * Update and create a DB config record for the given name/type, updates the last record only
 * @memberOf module:db
 * @method setConfig
 */
db.setConfig = function(options, callback)
{
    if (options.ctime) {
        return db.update("bk_config", options, { returning: "*", first: 1 }, callback);
    }
    this.select("bk_config", { type: options.type, name: options.name }, { last: 1 }, (err, row) => {
        this[row ? "update" : "put"]("bk_config", Object.assign({ ctime: row?.ctime }, options), { returning: "*", first: 1 }, callback);
    });
}