UNPKG

cloki

Version:

LogQL API with Clickhouse Backend

1,032 lines (973 loc) 33.2 kB
/* * cLoki DB Adapter for Clickhouse * (C) 2018-2022 QXIP BV */ const UTILS = require('../utils') const toJSON = UTILS.toJSON const logger = require('../logger') const { formatISO9075 } = require('date-fns') /* DB Helper */ const ClickHouse = require('@apla/clickhouse') const clickhouseOptions = { host: process.env.CLICKHOUSE_SERVER || 'localhost', port: process.env.CLICKHOUSE_PORT || 8123, auth: process.env.CLICKHOUSE_AUTH || 'default:', protocol: process.env.CLICKHOUSE_PROTO ? process.env.CLICKHOUSE_PROTO + ':' : 'http:', readonly: !!process.env.READONLY, queryOptions: { database: process.env.CLICKHOUSE_DB || 'cloki' } } const CORS = process.env.CORS_ALLOW_ORIGIN || '*' const transpiler = require('../../parser/transpiler') const rotationLabels = process.env.LABELS_DAYS || 7 const rotationSamples = process.env.SAMPLES_DAYS || 7 const axios = require('axios') const { samplesTableName, samplesReadTableName } = UTILS const path = require('path') const protocol = process.env.CLICKHOUSE_PROTO || 'http' // External Storage Policy for Tables (S3, MINIO) const storagePolicy = process.env.STORAGE_POLICY || false const { StringStream, DataStream } = require('scramjet') const { parseLabels, hashLabels } = require('../../common') const { Worker, isMainThread } = require('worker_threads') const jsonSerializer = (k, val) => typeof val === 'bigint' ? val.toString() : val const capabilities = {} let state = 'INITIALIZING' const clickhouse = new ClickHouse(clickhouseOptions) let ch const conveyor = { labels: 0, lastUpdate: 0, count: async () => { if (conveyor.lastUpdate < Date.now() - 30000) { return conveyor.labels } try { const resp = await rawRequest(`SELECT COUNT(1) as c FROM ${UTILS.DATABASE_NAME()}.time_series FORMAT JSON`) conveyor.labels = resp.data.data[0].c return conveyor.labels } catch (e) { logger.error(e) } } } let throttler = null const resolvers = {} const rejectors = {} if (isMainThread) { throttler = new Worker(path.join(__dirname, 'throttler.js')) throttler.on('message', (msg) => { switch (msg.status) { case 'ok': resolvers[msg.id]() break case 'err': rejectors[msg.id](new Error('Database push error')) break } delete resolvers[msg.id] delete rejectors[msg.id] }) } // timeSeriesv2Throttler.start(); /* Cache Helper */ const recordCache = require('record-cache') const { parseMs } = require('../utils') let id = 0 // Flushing to Clickhouse const bulk = { add: (values) => { id = id + 1 % 1e6 return new Promise((resolve, reject) => { throttler.postMessage({ type: 'values', data: values.map(r => JSON.stringify({ fingerprint: r[0], timestamp_ns: r[1], value: r[2], string: r[3] }, jsonSerializer)).join('\n'), id: id }) resolvers[id] = resolve rejectors[id] = reject }) } } const bulkLabels = { add: (values) => { return new Promise((resolve, reject) => { id = id + 1 % 1e6 throttler.postMessage({ type: 'labels', data: values.map(r => JSON.stringify({ date: r[0], fingerprint: r[1], labels: r[2], name: r[3] }, jsonSerializer)).join('\n'), id: id }) resolvers[id] = resolve rejectors[id] = reject }) } } // In-Memory LRU for quick lookups const labels = recordCache({ maxSize: process.env.BULK_MAXCACHE || 50000, maxAge: 0, onStale: false }) /* Initialize */ const initialize = async function (dbName) { logger.info('Initializing DB... ' + dbName) const tmp = { ...clickhouseOptions, queryOptions: { database: '' } } ch = new ClickHouse(tmp) const maintain = require('./maintain/index') await maintain.upgrade(dbName) await maintain.rotate([{ db: dbName, samples_days: rotationSamples, time_series_days: rotationLabels, storage_policy: storagePolicy }]) await checkCapabilities() await samplesReadTable.check() state = 'READY' reloadFingerprints() } const checkCapabilities = async () => { logger.info('Checking clickhouse capabilities') try { await axios.post(getClickhouseUrl() + '/?allow_experimental_live_view=1', `CREATE LIVE VIEW ${clickhouseOptions.queryOptions.database}.lvcheck WITH TIMEOUT 1 AS SELECT 1`) capabilities.liveView = true logger.info('LIVE VIEW: supported') } catch (e) { logger.info('LIVE VIEW: unsupported') capabilities.liveView = false } } const reloadFingerprints = function () { return; logger.info('Reloading Fingerprints...') const selectQuery = `SELECT DISTINCT fingerprint, labels FROM ${clickhouseOptions.queryOptions.database}.time_series` const stream = ch.query(selectQuery) // or collect records yourself const rows = [] stream.on('metadata', function (columns) { // do something with column list }) stream.on('data', function (row) { rows.push(row) }) stream.on('error', function (err) { logger.error({ err }, 'Error reloading fingerprints') }) stream.on('end', function () { rows.forEach(function (row) { try { const JSONLabels = toJSON(row[1].replace(/\!?=/g, ':')) labels.add(row[0], JSON.stringify(JSONLabels)) for (const key in JSONLabels) { // logger.debug('Adding key',row); labels.add('_LABELS_', key) labels.add(key, JSONLabels[key]) } } catch (err) { logger.error({ err }, 'error reloading fingerprints') } }) }) } const fakeStats = { summary: { bytesProcessedPerSecond: 0, linesProcessedPerSecond: 0, totalBytesProcessed: 0, totalLinesProcessed: 0, execTime: 0.001301608 }, store: { totalChunksRef: 0, totalChunksDownloaded: 0, chunksDownloadTime: 0, headChunkBytes: 0, headChunkLines: 0, decompressedBytes: 0, decompressedLines: 0, compressedBytes: 0, totalDuplicates: 0 }, ingester: { totalReached: 1, totalChunksMatched: 0, totalBatches: 0, totalLinesSent: 0, headChunkBytes: 0, headChunkLines: 0, decompressedBytes: 0, decompressedLines: 0, compressedBytes: 0, totalDuplicates: 0 } } const scanFingerprints = async function (query, res) { logger.debug('Scanning Fingerprints...') const _query = transpiler.transpile(query) _query.step = UTILS.parseOrDefault(query.step, 5) * 1000 return queryFingerprintsScan(_query, res) } const instantQueryScan = async function (query, res) { logger.debug('Scanning Fingerprints...') const time = parseMs(query.time, Date.now()) query.start = (time - 10 * 60 * 1000) * 1000000 query.end = Date.now() * 1000000 const _query = transpiler.transpile(query) _query.step = UTILS.parseOrDefault(query.step, 5) * 1000 const _stream = await axios.post(getClickhouseUrl() + '/', _query.query + ' FORMAT JSONEachRow', { responseType: 'stream' } ) const dataStream = preprocessStream(_stream, _query.stream || []) return await (_query.matrix ? outputQueryVector(dataStream, res) : outputQueryStreams(dataStream, res)) } const tempoQueryScan = async function (query, res, traceId) { logger.debug(`Scanning Tempo Fingerprints... ${traceId}`) const time = parseMs(query.time, Date.now()) /* Tempo does not seem to pass start/stop parameters. Use ENV or default 24h */ const hours = this.tempo_span || 24 if (!query.start) query.start = (time - (hours * 60 * 60 * 1000)) * 1000000 if (!query.end) query.end = Date.now() * 1000000 const _query = transpiler.transpile(query) _query.step = UTILS.parseOrDefault(query.step, 5) * 1000 const _stream = await axios.post(getClickhouseUrl() + '/', _query.query + ' FORMAT JSONEachRow', { responseType: 'stream' } ) const dataStream = preprocessStream(_stream, _query.stream || []) logger.info('debug tempo', query) return await (outputTempoSpans(dataStream, res, traceId)) } function getClickhouseUrl () { return `${protocol}://${clickhouseOptions.auth}@${clickhouseOptions.host}:${clickhouseOptions.port}` } /** * @param query { * {query: string, duration: number, matrix: boolean, stream: (function(DataStream): DataStream)[], step: number} * } * @param res {{res: {write: (function(string)), writeHead: (function(number, {}))}}} * @returns {Promise<void>} */ const queryFingerprintsScan = async function (query, res) { logger.debug('Scanning Fingerprints...') // logger.info(_query.query); const _stream = await getClickhouseStream(query) const dataStream = preprocessStream(_stream, query.stream || []) return await (query.matrix ? outputQueryMatrix(dataStream, res, query.step, query.duration) : outputQueryStreams(dataStream, res)) } /** * * @param query {{query: string}} * @returns {Promise<Stream>} */ const getClickhouseStream = (query) => { return axios.post(getClickhouseUrl() + '/', query.query + ' FORMAT JSONEachRow', { responseType: 'stream' } ) } /** * * @param dataStream {DataStream} * @param res {{res: { * write: (function(string)), * writeHead: (function(number, {})), * onBegin: (function(string)), * onEnd: (function(string)) * }}} * @param i {number} * @returns {Promise<void>} */ const outputQueryStreams = async (dataStream, res, i) => { res.res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': CORS }) const gen = dataStream.toGenerator() i = i || 0 let lastLabels = null let lastStream = [] res.res.onBegin ? res.res.onBegin('{"status":"success", "data":{ "resultType": "streams", "result": [') : res.res.write('{"status":"success", "data":{ "resultType": "streams", "result": [') for await (const item of gen()) { if (!item) { continue } if (!item.labels) { if (!lastLabels || !lastStream.length) { continue } res.res.write(i ? ',' : '') res.res.write(JSON.stringify({ stream: parseLabels(lastLabels), values: lastStream })) lastLabels = null lastStream = [] ++i continue } const hash = hashLabels(item.labels) const ts = item.timestamp_ns || null if (hash === lastLabels) { ts && lastStream.push([ts, item.string]) continue } if (lastLabels) { res.res.write(i ? ',' : '') res.res.write(JSON.stringify({ stream: parseLabels(lastLabels), values: lastStream })) ++i } lastLabels = hash lastStream = ts ? [[ts, item.string]] : [] } res.res.onEnd ? res.res.onEnd(']}}') : res.res.write(']}}') res.res.end() } /** * * @param dataStream {DataStream} * @param res {{res: {write: (function(string)), writeHead: (function(number, {}))}}} * @param stepMs {number} * @param durationMs {number} * @returns {Promise<void>} */ const outputQueryMatrix = async (dataStream, res, stepMs, durationMs) => { res.res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': CORS }) const addPoints = Math.ceil(durationMs / stepMs) const gen = dataStream.toGenerator() let i = 0 let lastLabels = null let lastStream = [] let lastTsMs = 0 res.res.write('{"status":"success", "data":{ "resultType": "matrix", "result": [') for await (const item of gen()) { if (!item) { continue } if (!item.labels) { if (!lastLabels || !lastStream.length) { continue } res.res.write(i ? ',' : '') res.res.write(JSON.stringify({ metric: parseLabels(lastLabels), values: lastStream })) lastLabels = null lastStream = [] lastTsMs = 0 ++i continue } const hash = hashLabels(item.labels) const ts = item.timestamp_ns ? parseInt(item.timestamp_ns) : null if (hash === lastLabels) { if (ts < (lastTsMs + stepMs)) { continue } for (let j = 0; j < addPoints; ++j) { ts && lastStream.push([(ts + stepMs * j) / 1000, item.value.toString()]) } lastTsMs = ts continue } if (lastLabels) { res.res.write(i ? ',' : '') res.res.write(JSON.stringify({ metric: parseLabels(lastLabels), values: lastStream })) ++i } lastLabels = hash lastStream = [] for (let j = 0; j < addPoints; ++j) { ts && lastStream.push([(ts + stepMs * j) / 1000, item.value.toString()]) } lastTsMs = ts } res.res.write(']}}') res.res.end() } /** * * @param dataStream {DataStream} * @param res {{res: {write: (function(string)), writeHead: (function(number, {}))}}} * @returns {Promise<void>} */ const outputQueryVector = async (dataStream, res) => { res.res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': CORS }) const gen = dataStream.toGenerator() let i = 0 let lastLabels = null let lastTsMs = 0 let lastValue = 0 res.res.write('{"status":"success", "data":{ "resultType": "vector", "result": [') for await (const item of gen()) { if (!item) { continue } if (!item.labels) { if (!lastLabels || !lastTsMs) { continue } res.res.write(i ? ',' : '') res.res.write(JSON.stringify({ metric: parseLabels(lastLabels), value: [lastTsMs / 1000, lastValue.toString()] })) lastLabels = null lastTsMs = 0 ++i continue } const hash = hashLabels(item.labels) const ts = item.timestamp_ns ? parseInt(item.timestamp_ns) : null if (hash === lastLabels) { lastTsMs = ts lastValue = item.value continue } if (lastLabels) { res.res.write(i ? ',' : '') res.res.write(JSON.stringify({ metric: parseLabels(lastLabels), value: [lastTsMs / 1000, lastValue.toString()] })) ++i } lastLabels = hash lastTsMs = ts lastValue = item.value } res.res.write(']}}') res.res.end() } /** * * @param dataStream {DataStream} * @param res {{res: {write: (function(string)), writeHead: (function(number, {}))}}} * @param traceId {String} * @returns {Promise<any>} */ const outputTempoSpans = async (dataStream, res, traceId) => { // res.res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': CORS }) const gen = dataStream.toGenerator() let i = 0 let response = '{"total": 0, "limit": 0, "offset": 0, "errors": null, "processes" : { "p1": {} }, "data": [ { "traceID": "' + traceId + '", ' response += '"spans":[' for await (const item of gen()) { if (!item || !item.string) { continue } response += (i ? ',' : '') response += item.string i++ } response += (']}]}') return response } /** * * @param rawStream {any} Stream from axios response * @param processors {(function(DataStream): DataStream)[] | undefined} * @returns {DataStream} */ const preprocessStream = (rawStream, processors) => { let dStream = StringStream.from(rawStream.data).lines().endWith(JSON.stringify({ EOF: true })) .map(chunk => chunk ? JSON.parse(chunk) : ({}), DataStream) .map(chunk => { try { if (!chunk || !chunk.labels) { return chunk } const labels = chunk.extra_labels ? { ...parseLabels(chunk.labels), ...parseLabels(chunk.extra_labels) } : parseLabels(chunk.labels) return { ...chunk, labels: labels } } catch (e) { logger.info(chunk) return chunk } }, DataStream) if (processors && processors.length) { processors.forEach(f => { dStream = f(dStream) }) } return dStream } /** * * @param rawStream {any} Stream from axios response * @param processors {(function(DataStream): DataStream)[] | undefined} * @returns {DataStream} */ const preprocessLiveStream = (rawStream, processors) => { let dStream = StringStream.from(rawStream.data).lines().endWith(JSON.stringify({ EOF: true })) .map(chunk => chunk ? JSON.parse(chunk) : ({}), DataStream) .filter(chunk => { return chunk && (chunk.row || chunk.EOF) }).map(chunk => ({ ...(chunk.row || {}), EOF: chunk.EOF })) .map(chunk => { try { if (!chunk || !chunk.labels) { return chunk } const labels = chunk.extra_labels ? { ...parseLabels(chunk.labels), ...parseLabels(chunk.extra_labels) } : parseLabels(chunk.labels) return { ...chunk, labels: labels } } catch (e) { logger.info(chunk) return chunk } }, DataStream) if (processors && processors.length) { processors.forEach(f => { dStream = f(dStream) }) } return dStream } /* cLoki Metrics Column */ const scanMetricFingerprints = function (settings, client, params) { logger.debug({ settings }, 'Scanning Clickhouse...') // populate matrix structure const resp = { status: 'success', data: { resultType: 'matrix', result: [] } } // Check for required fields or return nothing! if (!settings || !settings.table || !settings.db || !settings.tag || !settings.metric) { client.send(resp); return } settings.interval = settings.interval ? parseInt(settings.interval) : 60 if (!settings.timefield) settings.timefield = process.env.CLICKHOUSE_TIMEFIELD || 'record_datetime' const tags = settings.tag.split(',') let template = 'SELECT ' + tags.join(', ') + ', groupArray((toUnixTimestamp(timestamp_ns)*1000, toString(value))) AS groupArr FROM (SELECT ' if (tags) { tags.forEach(function (tag) { tag = tag.trim() template += " visitParamExtractString(labels, '" + tag + "') as " + tag + ',' }) } // if(settings.interval > 0){ template += ' toStartOfInterval(toDateTime(timestamp_ns/1000), INTERVAL ' + settings.interval + ' second) as timestamp_ns, value' + // } else { // template += " timestampMs, value" // } // template += " timestampMs, value" ' FROM ' + settings.db + '.samples RIGHT JOIN ' + settings.db + '.time_series ON samples.fingerprint = time_series.fingerprint' if (params.start && params.end) { template += ' WHERE ' + settings.timefield + ' BETWEEN ' + parseInt(params.start / 1000000000) + ' AND ' + parseInt(params.end / 1000000000) // template += " WHERE "+settings.timefield+" BETWEEN "+parseInt(params.start/1000000) +" AND "+parseInt(params.end/1000000) } if (tags) { tags.forEach(function (tag) { tag = tag.trim() template += " AND (visitParamExtractString(labels, '" + tag + "') != '')" }) } if (settings.where) { template += ' AND ' + settings.where } template += ' AND value > 0 ORDER BY timestamp_ns) GROUP BY ' + tags.join(', ') const stream = ch.query(template) // or collect records yourself const rows = [] stream.on('metadata', function (columns) { // do something with column list }) stream.on('data', function (row) { rows.push(row) }) stream.on('error', function (err) { // TODO: handler error client.code(400).send(err) }) stream.on('end', function () { logger.debug({ rows }, 'CLICKHOUSE RESPONSE') if (!rows || rows.length < 1) { resp.data.result = [] resp.data.stats = fakeStats } else { try { rows.forEach(function (row) { const metrics = { metric: {}, values: [] } const tags = settings.tag.split(',') // bypass empty blocks if (row[row.length - 1].length < 1) return // iterate tags for (let i = 0; i < row.length - 1; i++) { metrics.metric[tags[i]] = row[i] } // iterate values row[row.length - 1].forEach(function (row) { if (row[1] === 0) return metrics.values.push([parseInt(row[0] / 1000), row[1].toString()]) }) resp.data.result.push(metrics) }) } catch (err) { logger.error({ err }, 'Error scanning fingerprints') } } logger.debug({ resp }, 'CLOKI RESPONSE') client.send(resp) }) } /** * Clickhouse Metrics Column Query * @param settings {{ * db: string, * table: string, * interval: string | number, * tag: string, * metric: string * }} * @param client {{ * code: function(number): any, * send: function(string): any * }} * @param params {{ * start: string | number, * end: string | number, * shift: number | undefined * }} */ const scanClickhouse = function (settings, client, params) { logger.debug('Scanning Clickhouse...', settings) // populate matrix structure const resp = { status: 'success', data: { resultType: 'matrix', result: [] } } // TODO: Replace this template with a proper parser! // Check for required fields or return nothing! if (!settings || !settings.table || !settings.db || !settings.tag || !settings.metric) { client.send(resp); return } settings.interval = settings.interval ? parseInt(settings.interval) : 60 // Normalize timefield if (!settings.timefield) settings.timefield = process.env.TIMEFIELD || 'record_datetime' else if (settings.timefield === 'false') settings.timefield = false // Normalize Tags if (settings.tag.includes('|')) { settings.tag = settings.tag.split('|').join(',') } // Lets query! let template = 'SELECT ' + settings.tag + ', groupArray((t, c)) AS groupArr FROM (' // Check for timefield or Bypass timefield if (settings.timefield) { const shiftSec = params.shift ? params.shift / 1000 : 0 const timeReq = params.shift ? `intDiv(toUInt32(${settings.timefield} - ${shiftSec}), ${settings.interval}) * ${settings.interval} + ${shiftSec}` : 'intDiv(toUInt32(' + settings.timefield + '), ' + settings.interval + ') * ' + settings.interval template += `SELECT (${timeReq}) * 1000 AS t, ` + settings.tag + ', ' + settings.metric + ' c ' } else { template += 'SELECT toUnixTimestamp(now()) * 1000 AS t, ' + settings.tag + ', ' + settings.metric + ' c ' } template += 'FROM ' + settings.db + '.' + settings.table // Check for timefield or standalone where conditions if (params.start && params.end && settings.timefield) { template += ' PREWHERE ' + settings.timefield + ' BETWEEN ' + parseInt(params.start / 1000000000) + ' AND ' + parseInt(params.end / 1000000000) if (settings.where) { template += ' AND ' + settings.where } } else if (settings.where) { template += ' WHERE ' + settings.where } template += ' GROUP BY t, ' + settings.tag + ' ORDER BY t, ' + settings.tag + ')' template += ' GROUP BY ' + settings.tag + ' ORDER BY ' + settings.tag // Read-Only: Initiate a new driver connection if (process.env.READONLY) { const tmp = { ...clickhouseOptions, queryOptions: { database: settings.db } } ch = new ClickHouse(tmp) } const stream = ch.query(template) // or collect records yourself const rows = [] stream.on('metadata', function (columns) { // do something with column list }) stream.on('data', function (row) { rows.push(row) }) stream.on('error', function (err) { // TODO: handler error logger.error({ err }, 'error scanning clickhouse') resp.status = "error" resp.data.result = [] client.send(resp) }) stream.on('end', function () { logger.debug({ rows }, 'CLICKHOUSE RESPONSE') if (!rows || rows.length < 1) { resp.data.result = [] resp.data.stats = fakeStats } else { try { rows.forEach(function (row) { const metrics = { metric: {}, values: [] } const tags = settings.tag.split(',').map(t => t.trim()) // bypass empty blocks if (row[row.length - 1].length < 1) return // iterate tags for (let i = 0; i < row.length - 1; i++) { metrics.metric[tags[i]] = row[i] } // iterate values row[row.length - 1].forEach(function (row) { if (row[1] === 0) return metrics.values.push([parseInt(row[0] / 1000), row[1].toString()]) }) resp.data.result.push(metrics) }) } catch (err) { logger.error({ err }, 'error scanning clickhouse') } } logger.debug({ resp }, 'CLOKI RESPONSE') client.send(resp) }) } /** * * @param matches {string[]} ['{ts1="a1"}', '{ts2="a2"}', ...] * @param res {{res: {write: (function(string)), writeHead: (function(number, {}))}}} */ const getSeries = async (matches, res) => { const query = transpiler.transpileSeries(matches) const stream = await axios.post(`${getClickhouseUrl()}`, query + ' FORMAT JSONEachRow', { responseType: 'stream' }) const dStream = StringStream.from(stream.data).lines().map(l => { if (!l) { return null } try { return JSON.parse(l) } catch (err) { logger.error({ line: l, err }, 'Error parsing line') return null } }, DataStream).filter(e => e) const gen = dStream.toGenerator() res.res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': CORS }) res.res.write('{"status":"success", "data":[') let i = 0 for await (const item of gen()) { if (!item || !item.labels) { continue } res.res.write((i === 0 ? '' : ',') + item.labels) ++i } res.res.write(']}') res.res.end() } const ping = async () => { await Promise.all([ new Promise((resolve, reject) => ch.query('SELECT 1', undefined, (err) => { err ? reject(err) : resolve(err) })), axios.get(`${getClickhouseUrl()}/?query=SELECT 1`) ]) } /* Module Exports */ /** * * @param name {string} * @param request {string} * @param options {{db : string | undefined, timeout_sec: number | undefined}} */ module.exports.createLiveView = (name, request, options) => { const db = options.db || clickhouseOptions.queryOptions.database const timeout = options.timeout_sec ? `WITH TIMEOUT ${options.timeout_sec}` : '' return axios.post(`${getClickhouseUrl()}/?allow_experimental_live_view=1`, `CREATE LIVE VIEW ${db}.${name} ${timeout} AS ${request}`) } /** * * @param db {string} * @param name {string} * @param name {string} * @param res {{res: {write: (function(string)), writeHead: (function(number, {}))}}} * @param options {{ * stream: (function(DataStream): DataStream)[], * }} * @returns Promise<[Promise<void>, CancelTokenSource]> */ module.exports.watchLiveView = async (name, db, res, options) => { db = db || clickhouseOptions.queryOptions.database const cancel = axios.CancelToken.source() const stream = await axios.post(`${getClickhouseUrl()}/?allow_experimental_live_view=1`, `WATCH ${db}.${name} FORMAT JSONEachRowWithProgress`, { responseType: 'stream', cancelToken: cancel.token }) const endPromise = (async () => { const _stream = preprocessLiveStream(stream, options.stream) const gen = _stream.toGenerator() res.res.writeHead(200, {}) for await (const item of gen()) { if (!item || !item.labels) { continue } res.res.write(item) } res.res.end() })() return [endPromise, cancel] } module.exports.createMV = async (query, id, url) => { const request = `CREATE MATERIALIZED VIEW ${clickhouseOptions.queryOptions.database}.${id} ` + `ENGINE = URL('${url}', JSON) AS ${query}` logger.info(`MV: ${request}`) await axios.post(`${getClickhouseUrl()}`, request) } const samplesReadTable = { checked: false, v1: false, v1Time: false, versions: {}, getName: (fromMs) => { if (!samplesReadTable.checked) { return 'samples_read_v2_2' } if (!samplesReadTable.v1) { return 'samples_v3' } if (!fromMs || BigInt(fromMs + '000000') < samplesReadTable.v1Time) { return 'samples_read_v2_2' } return 'samples_v3' }, check: async function () { await this.settingsVersions() await this._check('samples_v2') if (samplesReadTable.v1) { return } await this._check('samples') }, checkVersion: function (ver, fromMs) { return samplesReadTable.versions[ver] < fromMs }, _check: async function (tableName) { try { logger.info('checking old samples support: ' + tableName) samplesReadTable.checked = true console.log(`${getClickhouseUrl()}/?database=${UTILS.DATABASE_NAME()}`) console.log('show tables format JSON') const tablesResp = await axios.post(`${getClickhouseUrl()}/?database=${UTILS.DATABASE_NAME()}`, 'show tables format JSON') samplesReadTable.v1 = tablesResp.data.data.find(row => row.name === tableName) if (!samplesReadTable.v1) { return } logger.info('checking last timestamp') const v1EndTime = await axios.post(`${getClickhouseUrl()}/?database=${UTILS.DATABASE_NAME()}`, `SELECT max(timestamp_ns) as ts FROM ${tableName} format JSON`) if (!v1EndTime.data.rows) { samplesReadTable.v1 = false return } samplesReadTable.v1 = true samplesReadTable.v1Time = BigInt(v1EndTime.data.data[0].ts) logger.warn('!!!WARNING!!! You use cLoki in the backwards compatibility mode! Some requests can be less efficient and cause OOM errors. To finish migration please look here: https://github.com/lmangani/cLoki/wiki/Upgrade') } catch (e) { logger.error(e.message) logger.error(e.stack) samplesReadTable.v1 = false logger.info('old samples table not supported') } finally { UTILS.onSamplesReadTableName(samplesReadTable.getName) } }, settingsVersions: async function () { const versions = await axios.post(`${getClickhouseUrl()}/?database=${UTILS.DATABASE_NAME()}`, `SELECT argMax(name, inserted_at) as _name, argMax(value, inserted_at) as _value FROM settings WHERE type == 'update' GROUP BY fingerprint HAVING _name != '' FORMAT JSON`) for (const version of versions.data.data) { this.versions[version._name] = parseInt(version._value) * 1000 } UTILS.onCheckVersion(samplesReadTable.checkVersion) } } /** * * @param query {string} * @param data {string | Buffer | Uint8Array} * @param database {string} * @returns {Promise<AxiosResponse<any>>} */ const rawRequest = (query, data, database) => { const getParams = [ (database ? `database=${encodeURIComponent(database)}` : null), (data ? `query=${encodeURIComponent(query)}` : null) ].filter(p => p) const url = `${getClickhouseUrl()}/${getParams.length ? `?${getParams.join('&')}` : ''}` return axios.post(url, data || query) } /** * * @param names {{type: string, name: string}[]} * @param database {string} * @returns {Promise<Object<string, string>>} */ const getSettings = async (names, database) => { const fps = names.map(n => UTILS.fingerPrint(JSON.stringify({ type: n.type, name: n.name }), false, 'short-hash')) const settings = await rawRequest(`SELECT argMax(name, inserted_at) as _name, argMax(value, inserted_at) as _value FROM settings WHERE fingerprint IN (${fps.join(',')}) GROUP BY fingerprint HAVING _name != '' FORMAT JSON`, null, database) return settings.data.data.reduce((sum, cur) => { sum[cur._name] = cur._value return sum }, {}) } /** * * @param type {string} * @param name {string} * @param value {string} * @param database {string} * @returns {Promise<void>} */ const addSetting = async (type, name, value, database) => { const fp = UTILS.fingerPrint(JSON.stringify({ type: type, name: name }), false, 'short-hash') return rawRequest('INSERT INTO settings (fingerprint, type, name, value, inserted_at) FORMAT JSONEachRow', JSON.stringify({ fingerprint: fp, type: type, name: name, value: value, inserted_at: formatISO9075(new Date()) }) + '\n', database) } module.exports.samplesReadTable = samplesReadTable module.exports.databaseOptions = clickhouseOptions module.exports.database = clickhouse module.exports.cache = { bulk: bulk, bulk_labels: bulkLabels, labels: labels } module.exports.scanFingerprints = scanFingerprints module.exports.queryFingerprintsScan = queryFingerprintsScan module.exports.instantQueryScan = instantQueryScan module.exports.tempoQueryScan = tempoQueryScan module.exports.scanMetricFingerprints = scanMetricFingerprints module.exports.scanClickhouse = scanClickhouse module.exports.reloadFingerprints = reloadFingerprints module.exports.init = initialize module.exports.preprocessStream = preprocessStream module.exports.capabilities = capabilities module.exports.ping = ping module.exports.stop = () => { throttler.postMessage({ type: 'end' }) throttler.removeAllListeners('message') throttler.terminate() } module.exports.ready = () => state === 'READY' module.exports.scanSeries = getSeries module.exports.outputQueryStreams = outputQueryStreams module.exports.samplesTableName = samplesTableName module.exports.samplesReadTableName = samplesReadTableName module.exports.getClickhouseUrl = getClickhouseUrl module.exports.getClickhouseStream = getClickhouseStream module.exports.preprocessLiveStream = preprocessLiveStream module.exports.rawRequest = rawRequest module.exports.getSettings = getSettings module.exports.addSetting = addSetting