db/cache.js

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

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

/**
 * Retrieve cached result or put a record into the cache prefixed with table:key[:key...]
 * Options accept the same parameters as for the usual get action but it is very important that all the options
 * be the same for every call, especially `select` parameters which tells which columns to retrieve and cache.
 * Additional options:
 * - prefix - prefix to be used for the key instead of table name
 *
 *  @example
 *  db.getCached("get", "bk_user", { login: req.query.login }, { select: "latitude,longitude" }, function(err, row) {
 *      var distance = lib.geoDistance(req.query.latitude, req.query.longitude, row.latitude, row.longitudde);
 *  });
 *
 * @memberof module:db
 * @method getCached
 */
db.getCached = function(op, table, query, options, callback)
{
    if (typeof options == "function") callback = options,options = null;
    if (typeof callback != "function") callback = lib.noop;
    table = this.alias(table);
    var pool = this.getPool(options), key;
    if (!options?.cacheKey && options?.cacheKeyName) {
        key = this.getCacheKeys(table, query, options.cacheKeyName)[0];
        if (!key) return false;
    }
    options = lib.objClone(options, { __cached: true });
    if (key) options.cacheKey = key;
    // Get the full record if not a specific cache
    if (!options.cacheKeyName) delete options.select;
    var req = this.prepare({ op, table, query, options });
    this.getCache(table, req.query, options, (data, cached) => {
        pool.metrics.cache.update(Date.now() - req.now);
        // Cached value retrieved
        if (data) data = lib.jsonParse(data);
        // Parse errors treated as miss
        if (data) {
            pool.metrics.hit_count++;
            return callback(null, data, { cached: cached });
        }
        pool.metrics.miss_count++;
        // Retrieve user from the database, use the parameters like in Select function
        db[op](table, query, options, (err, data, info) => {
            // Store in cache if no error
            if (data && !err) db.putCache(table, data, options);
            info.cached = 0;
            callback(err, data, info);
        });
    });
    return true;
}

// Retrieve an object from the cache by key, sets `cacheKey` in the options for later use
db.getCache = function(table, query, options, callback)
{
    var key = this.getCacheKey(table, query, options);
    if (!key) return callback();
    if (options) options.cacheKey = key;
    var ttl2 = this.getCache2Ttl(table, options);
    if (ttl2) {
        var val = this.lru.get(key);
        if (val) {
            logger.debug("getCache2:", "lru:", key, options, 'ttl2:', ttl2);
            return callback(val, 2);
        }
    }
    var opts = this.getCacheOptions(table, options);
    cache.get(key, opts, (err, val) => {
        if (!val) return callback();
        if (ttl2) {
            this.lru.put(key, val, Date.now() + ttl2);
        }
        logger.debug("getCache:", "ipc:", key, opts, 'ttl2:', ttl2);
        callback(val, 1);
    });
}

// Store a record in the cache
db.putCache = function(table, query, options)
{
    var key = options?.cacheKey || this.getCacheKey(table, query, options);
    if (!key) return;
    var val = lib.stringify(query);
    var ttl2 = this.getCache2Ttl(table, options);
    if (ttl2) {
        this.lru.put(key, val, Date.now() + ttl2);
    }
    var opts = this.getCacheOptions(table, options, 1);
    cache.put(key, val, opts);
    logger.debug("putCache:", key, opts, 'ttl2:', ttl2);
}

// Notify or clear cached record, this is called after del/update operation to clear cached version by primary keys
db.delCache = function(table, query, options)
{
    var key = options?.cacheKey || this.getCacheKey(table, query, options);
    if (!key) return;
    var ttl2 = this.getCache2Ttl(table, options);
    if (ttl2) {
        this.lru.del(key);
    }
    var opts = this.getCacheOptions(table, options, 1);
    cache.del(key, opts);
    logger.debug("delCache:", key, opts, 'ttl2:', ttl2);
}

// Returns concatenated values for the primary keys, this is used for caching records by primary key
db.getCacheKey = function(table, query, options)
{
    if (options?.cacheKey) return options.cacheKey;
    var keys = this.getKeys(table, options).filter((x) => (query[x])).map((x) => (query[x])).join(this.separator);
    if (keys) keys = (options?.cachePrefix || db.alias(table)) + this.separator + keys;
    return keys;
}

// Setup common cache properties
db.getCacheOptions = function(table, options, update)
{
    table = this.alias(table);
    var ttl = options?.cacheTtl || this.cacheTtl[table] || this.cacheTtl["*"];
    var cacheName = options?.cacheName ||
                    (update ? options?.pool && this.cacheUpdate[options.pool + "." + table] || this.cacheUpdate[table] || this.cacheUpdate["*"]: "") ||
                    options?.pool && this.cacheName[options.pool + "." + table] || this.cacheName[table] || this.cacheName["*"];
    if (ttl || cacheName) return { cacheName: cacheName, ttl: ttl };
    return null;
}

// Return TTL for level 2 cache, negative means use js cache
db.getCache2Ttl = function(table, options)
{
    table = this.alias(table);
    var pool = this.getPool(options);
    return this.cache2[pool.name + "-" + table] || this.cache2[table];
}

// Return a list of global cache keys, if a name is given only returns the matching key
db.getCacheKeys = function(table, query, name)
{
    table = this.alias(table);
    var keys = table && query ? this.cacheKeys[table] : null, rc = [];
    for (var p in keys) {
        var key = !name || p == name ? keys[p].map((x) => (query[x])).join(":") : null;
        if (key) rc.push(table + ":" + p + ":" + key);
    }
    return rc;
}

// Delete all global cache keys for the table record
db.delCacheKeys = function(req, result, options, callback)
{
    var cached = req.table && req.query && (options?.cached || this.cacheTables.includes(req.table));
    var keys = [];

    switch (req.op) {
    case "add":
        keys = this.getCacheKeys(req.table, req.query);
        break;
    case "put":
    case "update":
    case "incr":
        keys = this.getCacheKeys(req.table, req.query);
        if (cached) keys.push(this.getCacheKey(req.table, req.query, options));
        if (options?.returning == "*" && result?.length) keys.push.apply(keys, this.getCacheKeys(req.table, result[0]));
        break;
    case "del":
        keys = this.getCacheKeys(req.table, req.query);
        if (cached) keys.push(this.getCacheKey(req.table, req.query, options));
        if (options?.returning && result?.length) keys.push.apply(keys, this.getCacheKeys(req.table, result[0]));
        break;
    }
    if (!keys.length) return lib.tryCall(callback, NaN);

    var ttl2 = this.getCache2Ttl(req.table, options);
    if (ttl2) {
        for (const i in keys) this.lru.del(keys[i]);
    }
    var opts = this.getCacheOptions(req.table, options, 1);
    logger.debug("delCacheKeys:", keys, opts, options);
    cache.del(keys, opts, callback);
}