/** * @copyright Copyright (c) Open-Xchange GmbH, Germany <info@open-xchange.com> * @license AGPL-3.0 * * This code is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero 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 Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with OX App Suite. If not, see <https://www.gnu.org/licenses/agpl-3.0.txt>. * * Any use of the work other than as authorized under this license or copyright law is prohibited. */ import { NotFoundError } from './errors.js' import logger from './logger.js' import * as redis from './redis.js' import { getRedisKey } from './util.js' import { Gauge } from 'prom-client' const cache = {} function createWritable (body) { return (typeof body !== 'string' && !(body instanceof Buffer)) ? JSON.stringify(body) : body } export const fileCacheSizeGauge = new Gauge({ name: 'file_cache_size', help: 'Number of entries in file cache' }) export const versionCountGauge = new Gauge({ name: 'redis_version_count_gauge', help: 'Number of cache versions stored in Redis', async collect () { const pattern = `${process.env.REDIS_PREFIX}:*:mergedMetadata` const results = await redis.client.keys(pattern) this.set(results.length) } }) export const usedMemoryGauge = new Gauge({ name: 'redis_used_ram', help: 'Total used memory in human readable units and as a percentage (RSS / total memory)', labelNames: ['memory_type'], async collect () { redis.client.info('memory', (err, result) => { if (err) { throw err } // Parse the result const infoLines = result.split('\r\n') const memoryInfo = {} for (const line of infoLines) { const [key, value] = line.split(':') if (key && value) { memoryInfo[key] = value } } const usedMemoryRSS = parseFloat(memoryInfo.used_memory_rss) const memoryTotal = parseFloat(memoryInfo.total_system_memory) this.set({ memory_type: 'usedMemoryRSSHuman' }, usedMemoryRSS) this.set({ memory_type: 'totalUsedMemoryPercentage' }, usedMemoryRSS / memoryTotal) }) } }) export function set (key, value, timeout) { logger.debug(`[Cache] Set ${key}`) if (cache[key] === value) return cache[key] = value if (timeout) setTimeout(expire, timeout * 1000, key) if (timeout) return redis.client.set(key, value, 'EX', timeout).catch(err => logger.error(err)) return redis.client.set(key, value).catch(err => logger.error(err)) } export async function clear () { for (const prop of Object.getOwnPropertyNames(cache)) { delete cache[prop] } fileCacheSizeGauge.reset() } export async function get (key, fallback) { if (cache[key]) { logger.debug(`[Cache] Resolve from memory: ${key}`) return cache[key] } const promise = (async () => { let result = await redis.client.get(key).catch(err => logger.error(err)) if (result) { logger.debug(`[Cache] Resolve from redis: ${key}`) result = JSON.parse(result) cache[key] = result redis.client.ttl(key).then(timeout => { if (timeout > 0) setTimeout(expire, timeout * 1000, key) }) return result } if (!fallback) return const [fallbackResult, timeout] = await fallback() if (fallbackResult) { logger.debug(`[Cache] Found a getter for: ${key}`) set(key, JSON.stringify(fallbackResult), timeout) // overwrite local cache again, as set() will store the stringified version cache[key] = fallbackResult } return fallbackResult })() cache[key] = promise return promise } export function getFile ({ name, version }, fallback) { const key = getRedisKey({ version, name }) // try to get the file synchronously. const data = cache[key] if (data) { logger.debug(`[Cache] Resolve file from memory: ${key}`) return data } // if synchronously does not work, store the async promise for further requests const promise = (async () => { const bodyKey = getRedisKey({ version, name: `${name}:body` }) const metaKey = getRedisKey({ version, name: `${name}:meta` }) const [body, meta = '{}'] = await Promise.all([ redis.client.getBuffer(bodyKey), redis.client.get(metaKey) ]).catch((err) => { logger.error(`[Cache] could not access redis: ${err}`) return [] }) if (body) { logger.debug(`[Cache] Resolve file from redis: ${key}`) fileCacheSizeGauge.inc() return (cache[key] = { body, ...JSON.parse(meta) }) } if (!fallback) { delete cache[key] throw new NotFoundError(`[Cache] Not found: ${key}`) } const dataFromServer = await fallback({ version, name }).catch(err => { if (err.status !== 404) delete cache[key] throw err }) { logger.debug(`[Cache] Store file in redis: ${key}`) const { body, ...rest } = dataFromServer redis.client.set(bodyKey, createWritable(body)).catch(err => logger.error(`[Cache] could not store ${bodyKey}: ${err}`)) redis.client.set(metaKey, JSON.stringify(rest)).catch(err => logger.error(`[Cache] could not store ${metaKey}: ${err}`)) } // overwrite cache with synchronous data logger.debug(`[Cache] Store file in memory: ${key}`) fileCacheSizeGauge.inc() return (cache[key] = dataFromServer) })() // temporary set to promise cache[key] = promise return promise } function expire (key) { logger.debug(`[Cache] Key ${key} has expired.`) fileCacheSizeGauge.dec() delete cache[key] }