UNPKG

strapi-pfapi

Version:

a strapi plugin library uses local and redis caches to achieve single digit milliseconds on average api response time.

299 lines (276 loc) 10.2 kB
'use strict'; const fp = require('lodash/fp'); const { info_keys, update_info } = require('./info-keys'); const get_checksum = require('../utils/get-checksum'); const get_cache_key = require('../utils/get-cache-key'); const get_config = require('../app/get-config'); const get_body = require('../utils/get-body'); const get_value = require('../utils/get-value'); const get_dependent_keys = require('../app/get-dependent-keys'); const update_params = require('../utils/update-params'); const debug = require('debug')('pfapi:cacheable'); const Refreshable = require('./refreshable'); class Cacheable { /** * constructor * * @param {*} object can have keys in info_keys, 'key', 'data', 'dependent_keys' and refreshable object * @param {*} config for testing and per instance config */ constructor(object, config) { if (object.refreshable) { this.refreshable = object.refreshable; this.module_path = this.refreshable.module_path; } this.plain_object = object; this.config = get_config('Cacheable'); if (config) Object.assign(this.config, config); if (process.env.NODE_ENV === 'test') this.from = ''; } /** * it tries to get the data from local cache first, then redis cache, finally from refreshable get_data * it updates caches that don't have the data * * @param {*} local_cache an instance of LocalCache * @param {*} redis_cache an instance of RedisCache * @returns true if succeeded, otherwise false */ async get(local_cache, redis_cache) { if (local_cache && local_cache.load(this)) { if (process.env.NODE_ENV === 'test') this.from = 'local'; return true; } if (redis_cache) { if (await redis_cache.get_cacheable(this)) { if (local_cache) local_cache.save(this); if (process.env.NODE_ENV === 'test') this.from = 'redis'; if (this.is_refreshable) this.early_refresh(redis_cache); return true; } } if (await this.fetch_data(redis_cache)) { if (local_cache && this.data !== undefined) { local_cache.save(this); } if (process.env.NODE_ENV === 'test') this.from = 'fetch'; return true; } return false; } /** * delete the cacheable from redis and local cache * * @param {*} local_cache an instance of LocalCache * @param {*} redis_cache an instance of RedisCache * @param {*} ignore_invalidation ignore invalidation */ async del(local_cache, redis_cache, ignore_invalidation = false) { if (redis_cache) await redis_cache.delete(this, ignore_invalidation); if (local_cache) local_cache.delete(this.key); } /** * convert to plain old javascript object * * @returns */ get plain_object() { const plain_object = {key: this.key, data: this.data}; for (const key of info_keys) { if (key === 'ttl') { plain_object[key] = this.data_ttl; continue; } if (this.hasOwnProperty(key)) { plain_object[key] = this[key]; } } return plain_object; } /** * convert from plain object * * @param {*} plain_object */ set plain_object(plain_object) { if (plain_object.key) this.key = plain_object.key; if (plain_object.data !== undefined) this.data = plain_object.data; if (plain_object.dependent_keys) this.dependent_keys = plain_object.dependent_keys; for (const key of info_keys) { if (plain_object.hasOwnProperty(key)) { if (key === 'ttl' && this.ttl) continue; this[key] = plain_object[key]; } } if (!this.refreshable && this.module_path) { this.refreshable = new Refreshable(this.module_path); } if (!this.params) this.params = {}; if (this.refreshable) { const handle = this.params.handle; this.params = this.refreshable.reduce(this.params); if (handle && !this.params.handle) this.params.handle = handle; for (const [key, value] of Object.entries(this.params)) { if (value === undefined) delete this.params[key]; } const key = get_cache_key(this); if (!this.key) this.key = key; else if (this.key !== key) { delete this.timestamp; delete this.created_time; delete this.duration; this.key = key; } } else if (!this.key) { for (const [key, value] of Object.entries(this.params)) { if (value === undefined) delete this.params[key]; } this.key = get_cache_key(this); } if (!this.checksum && this.hasOwnProperty('data')) { this.checksum = get_checksum(this.data); } } // helper function, update info from redis result // set info(result) { update_info(this, result); if (this.module_path && !this.refreshable) { this.refreshable = new Refreshable(this.module_path); } } // helper function, prepare for redis set data // get data_value() { return get_body(this.data); } // helper function, parse result from redis get data // set data_value(value) { this.data = get_value(value); } // helper function, prepare hmset args for info // get info_args() { // count does't update, only do increments const args = []; for (const key of info_keys) { if (key === 'count' || !this.hasOwnProperty(key)) continue; const value = key === 'ttl' ? this.data_ttl : this[key]; if (value === undefined) continue; args.push(key, get_body(this[key])); } return args; } get is_refreshable() { return !!this.module_path; } // to delay the decision of using default to the time needs it // get data_ttl() { if (!this.hasOwnProperty('ttl')) { this.ttl = this.config.ttl || 900; } return this.ttl; } // for slow get_data calls, extra_ttl will help the refresh mechanism // to avoid calling it during serving the actual request // get extra_ttl() { return this.duration >= this.config.slow_duration ? this.config.extra_ttl : 0; } get info_ttl() { return this.config.info_ttl; // typical 24 hours } // call within redis_cache, if it is fast enough, no need to setup refresh // get to_refresh() { return this.refreshable && this.duration >= this.config.refresh_duration; } /** * for costly slow get data from source, * we can do scheduled fetch when it closes to expiration * without delay on current call * * @param {*} redis_cache */ early_refresh(redis_cache) { if (!this.timestamp || !this.duration) return; if (this.duration < this.early_refresh_duration) return; if (Date.now() - this.timestamp < this.config.early_refresh_start) return; this.scheduled_fetch(redis_cache); } /** * * no blocking async scheduled fetch data * * @param {*} redis_cache */ scheduled_fetch(redis_cache, after_ms = 100) { if (!redis_cache) return; const handle = setTimeout(async () => { if (await this.fetch_data(redis_cache)) { if (process.env.NODE_ENV === 'test') this.from = 'scheduled_fetch'; } clearTimeout(handle); }, after_ms); handle.unref(); } /** * fetch data from source and save result to redis * * @param {*} redis_cache * @returns */ async fetch_data(redis_cache) { if (!this.params || !this.module_path) { if (!redis_cache || !await redis_cache.get_info(this)) { return false; } } if (!this.params || !this.module_path) { return false; } if (!this.refreshable) { this.refreshable = new Refreshable(this.module_path); } await this.get_data(); if (redis_cache) { await redis_cache.set_cacheable(this); } return true; } /** * @param {*} config for supporting preview * get_data, it consequently calls to get_data of refreshable. * * @returns no returns, throw exception if fails */ async get_data(config) { const previous_checksum = this.checksum; const start_ms = Date.now(); const params = fp.cloneDeep(this.params); const config_key = update_params(params, config); const result = await this.refreshable.get_data(params); if (!result || result.data === undefined || result.data === null) { throw new Error('Not Found - get_data'); } this.data = result.data; this.timestamp = Date.now(); if (!this.created_time) this.created_time = this.timestamp; this.duration = this.timestamp - start_ms; const ttl = this?.tll || this.config.ttl; if (ttl < this.duration) this.ttl = this.duration; this.checksum = get_checksum(this.data); const changed = previous_checksum !== this.checksum; if (changed) this.modified_time = this.timestamp; if (result.content_type) this.content_type = result.content_type; if (result.dependencies) this.dependent_keys = get_dependent_keys(result.dependencies); if (config_key) { if (this.dependent_keys) this.dependent_keys.push(config_key); else this.dependent_keys = [ config_key ]; } debug('config key', config_key); debug('dependent_keys', this.dependent_keys ? this.dependent_keys.length : null); } } module.exports = Cacheable;