/** * Device-mapper cache stats (dm-cache / dm-cache target). * * Auto when dm cache devices are present. * Disable: PEARDATA_DMCACHE=0 * * Charts: dmcache.stats */ import fs from 'fs' import path from 'path' import { CollectorPlugin } from './plugin.js' import { registerChart } from '../../../shared/metrics.js' import { execFile } from '../../utils/exec.js' import logger from '../../utils/logger.js' const log = logger.child('dmcache') const CHART_STATS = { id: 'dmcache.stats', name: 'dmcache.stats', context: 'dmcache.stats', title: 'dm-cache stats', units: 'count', family: 'dmcache', chartType: 'line', priority: 4340, plugin: 'dmcache', dimensions: [ { id: 'hits', name: 'hits', algorithm: 'absolute' }, { id: 'misses', name: 'misses', algorithm: 'absolute' }, ], } export function isDmcacheEnabled() { const v = process.env.PEARDATA_DMCACHE if (v === '0' || v === 'off' || v === 'false') return false if (v === '1' || v === 'on' || v === 'true') return true return discoverDmCacheDevices().length > 0 } function readFile(p) { try { return fs.readFileSync(p, 'utf8') } catch { return null } } /** * @returns {string[]} */ export function discoverDmCacheDevices() { /** @type {string[]} */ const found = [] const blockRoot = '/sys/block' if (!fs.existsSync(blockRoot)) return found for (const name of fs.readdirSync(blockRoot)) { if (!name.startsWith('dm-')) continue const dmName = readFile(path.join(blockRoot, name, 'dm', 'name')) if (!dmName) continue const lower = dmName.toLowerCase() if (lower.includes('cache') || lower.includes('dmcache')) { found.push(name) } } return found } /** * @param {string} output * @returns {{ hits: number|null, misses: number|null }} */ export function parseDmsetupStatus(output) { let hits = null let misses = null for (const line of String(output || '').split('\n')) { const h = line.match(/(\d+)\s+hits?/i) const m = line.match(/(\d+)\s+miss(?:es)?/i) if (h) hits = (hits ?? 0) + Number(h[1]) if (m) misses = (misses ?? 0) + Number(m[1]) } return { hits, misses } } /** * @returns {Promise<{ hits: number|null, misses: number|null }>} */ export async function collectDmcacheStats() { const devices = discoverDmCacheDevices() if (!devices.length) return { hits: null, misses: null } try { const { stdout } = await execFile('dmsetup', ['status'], { timeout: 3000 }) const parsed = parseDmsetupStatus(stdout) if (parsed.hits != null || parsed.misses != null) return parsed } catch { // fall through to sysfs } let hits = null let misses = null for (const dev of devices) { const statsDir = path.join('/sys/block', dev, 'dm', 'stats') const raw = readFile(statsDir) || readFile(path.join('/sys/block', dev, 'stat')) if (!raw) continue const h = raw.match(/hits?\s+(\d+)/i) const m = raw.match(/miss(?:es)?\s+(\d+)/i) if (h) hits = (hits ?? 0) + Number(h[1]) if (m) misses = (misses ?? 0) + Number(m[1]) } return { hits, misses } } export class DmcacheCollector extends CollectorPlugin { constructor(opts = {}) { super({ name: 'dmcache', intervalMs: opts.intervalMs }) } isEnabled() { return isDmcacheEnabled() } start() { if (!this.isEnabled()) return registerChart(CHART_STATS) log.info('dm-cache collector started') super.start() } async collect() { const stats = await collectDmcacheStats() if (stats.hits == null && stats.misses == null) return [] const ts = Date.now() return [ { chart: 'dmcache.stats', context: 'dmcache.stats', ts, values: { hits: stats.hits, misses: stats.misses }, }, ] } } /** @type {DmcacheCollector|null} */ let singleton = null export function getDmcacheCollector() { if (!singleton) singleton = new DmcacheCollector() return singleton }