clickhouse/clickhouse.go
Rama main · réplica de lectura del repositorio original.
package clickhouse
import (
"context"
"database/sql"
"errors"
"fmt"
"math"
"net"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
"github.com/coroot/coroot/ch"
"github.com/coroot/coroot/db"
"k8s.io/klog"
)
const (
dialTimeout = 10 * time.Second
)
type Client struct {
conn clickhouse.Conn
useDistributed bool
cloud bool
project *db.Project
}
func NewClient(config *db.IntegrationClickhouse, project *db.Project) (*Client, error) {
var err error
var dialer *ch.Dialer
if config.Protocol == ch.ProtocolCoroot {
origConfig := config
if config, err = ch.GetConfigFromRemoteCoroot(origConfig); err != nil {
return nil, err
}
dialer = ch.GetRemoteCorootDialer(origConfig)
}
opts := &clickhouse.Options{
Addr: []string{config.Addr},
Auth: clickhouse.Auth{
Database: config.Database,
Username: config.Auth.User,
Password: config.Auth.Password,
},
Compression: &clickhouse.Compression{Method: clickhouse.CompressionLZ4},
DialTimeout: dialTimeout,
//Debug: true,
//Debugf: func(format string, v ...interface{}) {
// klog.Infof(format, v...)
//},
}
switch config.Protocol {
case "native":
opts.Protocol = clickhouse.Native
case "http":
opts.Protocol = clickhouse.HTTP
default:
return nil, fmt.Errorf("unknown protocol: %s", config.Protocol)
}
if dialer != nil {
opts.DialContext = dialer.Dial
}
if config.TlsEnable && dialer == nil {
opts.TLS = ch.TlsConfig(config.TlsCAFile, config.TlsSkipVerify)
}
conn, err := clickhouse.Open(opts)
if err != nil {
return nil, err
}
c := &Client{conn: conn, project: project}
if err := c.discoverCluster(); err != nil {
klog.Warningln("failed to discover ClickHouse cluster info:", err)
}
return c, nil
}
func (c *Client) discoverCluster() error {
ctx, cancel := context.WithTimeout(context.Background(), dialTimeout)
defer cancel()
var exists uint8
if err := c.conn.QueryRow(ctx, "EXISTS system.zookeeper").Scan(&exists); err != nil {
return err
}
if exists != 1 {
return nil
}
var modeStr string
err := c.conn.QueryRow(ctx, "SELECT value FROM system.settings WHERE name = 'cloud_mode_engine'").Scan(&modeStr)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if modeStr != "" {
mode, _ := strconv.ParseUint(modeStr, 10, 64)
if mode >= 2 {
c.cloud = true
return nil
}
}
var count uint64
if err := c.conn.QueryRow(ctx, "SELECT count(DISTINCT cluster) FROM system.clusters").Scan(&count); err != nil {
return err
}
if count > 0 {
c.useDistributed = true
}
return nil
}
func (c *Client) Project() *db.Project {
return c.project
}
func (c *Client) Cloud() bool {
return c.cloud
}
func (c *Client) Ping(ctx context.Context) error {
return c.conn.Ping(ctx)
}
func (c *Client) ClusterId() string {
if c.project == nil {
return ""
}
return c.project.ClusterId()
}
func (c *Client) Query(ctx context.Context, query string, args ...any) (driver.Rows, error) {
query = ch.ReplaceTables(query, c.useDistributed)
return c.conn.Query(ctx, query, args...)
}
func (c *Client) QueryRow(ctx context.Context, query string, args ...any) driver.Row {
query = ch.ReplaceTables(query, c.useDistributed)
return c.conn.QueryRow(ctx, query, args...)
}
func (c *Client) Close() error {
if c == nil {
return nil
}
return c.conn.Close()
}
func (c *Client) getClusterTopology(ctx context.Context) ([]ClusterNode, error) {
var clusterName string
if c.cloud {
clusterName = "default"
} else {
clusterQuery := `
SELECT DISTINCT replaceRegexpOne(engine_full, '^Distributed\\(''([^'']+)''.*', '\\1') as cluster_name
FROM system.tables
WHERE engine = 'Distributed'
AND database = currentDatabase()
LIMIT 1`
row := c.conn.QueryRow(ctx, clusterQuery)
if err := row.Scan(&clusterName); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return []ClusterNode{}, nil
}
return nil, err
}
}
topologyQuery := `
SELECT cluster, shard_num, replica_num, host_name, port
FROM system.clusters
WHERE cluster = ?
ORDER BY shard_num, replica_num`
rows, err := c.conn.Query(ctx, topologyQuery, clusterName)
if err != nil {
return nil, err
}
defer rows.Close()
var nodes []ClusterNode
for rows.Next() {
var node ClusterNode
if err := rows.Scan(&node.Cluster, &node.ShardNum, &node.ReplicaNum, &node.HostName, &node.Port); err != nil {
return nil, err
}
nodes = append(nodes, node)
}
return nodes, rows.Err()
}
func (c *Client) GetTableSizes(ctx context.Context) ([]TableInfo, error) {
query := `
SELECT
p.database,
p.table,
sum(p.bytes_on_disk) as bytes_on_disk,
sum(p.data_uncompressed_bytes) as data_uncompressed_bytes,
extract(
t.create_table_query,
'TTL .+\\+ (INTERVAL \\d+ [A-Z]+|toInterval\\w+\\(\\d+\\))'
) AS ttl_expr,
min(p.min_time) as data_since
FROM system.parts p
LEFT JOIN system.tables t ON p.database = t.database AND p.table = t.name
WHERE p.active = 1
AND p.min_time > 0
AND p.database = currentDatabase()
AND p.engine NOT LIKE '%Distributed%'
AND (p.table LIKE 'otel_%' OR p.table LIKE 'profiling_%' OR p.table LIKE 'metrics%')
GROUP BY p.database, p.table, t.create_table_query
ORDER BY p.table`
rows, err := c.conn.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var rawTables []TableInfo
for rows.Next() {
var table TableInfo
var ttlExpr *string
var dataSince *time.Time
if err := rows.Scan(
&table.Database,
&table.Table,
&table.BytesOnDisk,
&table.DataUncompressedBytes,
&ttlExpr,
&dataSince,
); err != nil {
return nil, err
}
if ttlExpr != nil && *ttlExpr != "" {
table.TTLInfo = *ttlExpr
if seconds := parseTTLToSeconds(*ttlExpr); seconds > 0 {
table.TTLSeconds = &seconds
}
}
if dataSince != nil {
table.DataSince = dataSince
}
if table.BytesOnDisk > 0 {
table.CompressionRatio = float64(table.DataUncompressedBytes) / float64(table.BytesOnDisk)
} else {
table.CompressionRatio = 0
}
rawTables = append(rawTables, table)
}
if err := rows.Err(); err != nil {
return nil, err
}
return rawTables, nil
}
func (c *Client) GetDiskInfo(ctx context.Context) ([]DiskInfo, error) {
query := `
SELECT
name,
path,
free_space,
total_space,
type
FROM system.disks
ORDER BY name`
rows, err := c.conn.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var disks []DiskInfo
for rows.Next() {
var disk DiskInfo
if err := rows.Scan(
&disk.Name,
&disk.Path,
&disk.FreeSpace,
&disk.TotalSpace,
&disk.Type,
); err != nil {
return nil, err
}
if disk.FreeSpace == math.MaxUint64 {
disk.FreeSpace = 0
}
if disk.TotalSpace == math.MaxUint64 {
disk.TotalSpace = 0
}
disks = append(disks, disk)
}
if err := rows.Err(); err != nil {
return nil, err
}
return disks, nil
}
func parseTTLToSeconds(ttlExpr string) uint64 {
ttlExpr = strings.TrimSpace(ttlExpr)
intervalRegex := regexp.MustCompile(`INTERVAL\s+(\d+)\s+([A-Z]+)`)
if matches := intervalRegex.FindStringSubmatch(ttlExpr); len(matches) == 3 {
value, err := strconv.ParseUint(matches[1], 10, 64)
if err != nil {
return 0
}
unit := strings.ToUpper(matches[2])
return convertIntervalToSeconds(value, unit)
}
toIntervalRegex := regexp.MustCompile(`toInterval([A-Za-z]+)\((\d+)\)`)
if matches := toIntervalRegex.FindStringSubmatch(ttlExpr); len(matches) == 3 {
value, err := strconv.ParseUint(matches[2], 10, 64)
if err != nil {
return 0
}
unit := strings.ToUpper(matches[1])
return convertIntervalToSeconds(value, unit)
}
return 0
}
func convertIntervalToSeconds(value uint64, unit string) uint64 {
switch unit {
case "SECOND", "SECONDS":
return value
case "MINUTE", "MINUTES":
return value * 60
case "HOUR", "HOURS":
return value * 3600
case "DAY", "DAYS":
return value * 86400
case "WEEK", "WEEKS":
return value * 604800
case "MONTH", "MONTHS":
return value * 2592000 // 30 days
case "QUARTER", "QUARTERS":
return value * 7776000 // 90 days
case "YEAR", "YEARS":
return value * 31536000 // 365 days
default:
return 0
}
}
type TableInfo struct {
Database string `json:"database"`
Table string `json:"table"`
BytesOnDisk uint64 `json:"bytes_on_disk"`
DataUncompressedBytes uint64 `json:"data_uncompressed_bytes"`
CompressionRatio float64 `json:"compression_ratio"`
TTLInfo string `json:"ttl_info,omitempty"`
TTLSeconds *uint64 `json:"ttl_seconds,omitempty"`
DataSince *time.Time `json:"data_since,omitempty"`
}
type DiskInfo struct {
Name string `json:"name"`
Path string `json:"path"`
FreeSpace uint64 `json:"free_space"`
TotalSpace uint64 `json:"total_space"`
Type string `json:"type"`
}
type ServerDiskInfo struct {
Addr string `json:"addr"`
Disks []DiskInfo `json:"disks,omitempty"`
Error string `json:"error,omitempty"`
}
type ServerResult struct {
Addr string
Data any
Error error
}
type ClusterNode struct {
Cluster string `json:"cluster"`
ShardNum uint32 `json:"shard_num"`
ReplicaNum uint32 `json:"replica_num"`
HostName string `json:"host_name"`
Port uint16 `json:"port"`
}
type ClusterInfo struct {
Topology []ClusterNode `json:"topology,omitempty"`
TableSizes []TableInfo `json:"table_sizes,omitempty"`
ServerDisks []ServerDiskInfo `json:"server_disks,omitempty"`
}
func GetClusterInfo(ctx context.Context, cfg *db.IntegrationClickhouse, project *db.Project) (*ClusterInfo, error) {
ch, err := NewClient(cfg, project)
if err != nil {
return nil, err
}
ci := &ClusterInfo{}
if ci.Topology, err = ch.getClusterTopology(ctx); err != nil {
klog.Errorln("failed to get ClickHouse cluster topology:", err)
return ci, nil
}
if ci.TableSizes, err = getClusterTableSizes(ctx, cfg, project, ci.Topology, ch); err != nil {
klog.Errorln("failed to get ClickHouse table sizes:", err)
return ci, nil
}
if ci.ServerDisks, err = getClusterServerDisks(ctx, cfg, project, ci.Topology, ch); err != nil {
klog.Errorln("failed to get ClickHouse server disks:", err)
return ci, nil
}
return ci, nil
}
func executeOnAllServers(ctx context.Context, config *db.IntegrationClickhouse, topology []ClusterNode, project *db.Project, operation func(*Client) (any, error)) ([]ServerResult, error) {
serverAddrs := make(map[string]bool)
for _, node := range topology {
serverAddrs[net.JoinHostPort(node.HostName, strconv.Itoa(int(node.Port)))] = true
}
if len(serverAddrs) == 0 {
serverAddrs[config.Addr] = true
}
type serverExecResult struct {
addr string
result any
err error
}
results := make([]ServerResult, 0, len(serverAddrs))
resultsChan := make(chan serverExecResult, len(serverAddrs))
var wg sync.WaitGroup
for addr := range serverAddrs {
wg.Add(1)
go func(addr string) {
defer wg.Done()
clientConfig := config
clientConfig.Addr = addr
client, err := NewClient(clientConfig, project)
if err != nil {
resultsChan <- serverExecResult{addr: addr, err: err}
return
}
defer client.Close()
result, err := operation(client)
resultsChan <- serverExecResult{addr: addr, result: result, err: err}
}(addr)
}
go func() {
wg.Wait()
close(resultsChan)
}()
for {
select {
case result, ok := <-resultsChan:
if !ok {
return results, nil
}
results = append(results, ServerResult{
Addr: result.addr,
Data: result.result,
Error: result.err,
})
case <-ctx.Done():
return results, ctx.Err()
}
}
}
func getClusterTableSizes(ctx context.Context, config *db.IntegrationClickhouse, project *db.Project, topology []ClusterNode, ch *Client) ([]TableInfo, error) {
if ch.cloud {
allTables, err := ch.GetTableSizes(ctx)
if err != nil {
return nil, err
}
return aggregateTableStats(allTables), nil
}
results, err := executeOnAllServers(ctx, config, topology, project, func(client *Client) (any, error) {
return client.GetTableSizes(ctx)
})
if err != nil {
return nil, err
}
var allTables []TableInfo
for _, result := range results {
if result.Error != nil {
klog.Warningf("failed to get table sizes from server %s: %v", result.Addr, result.Error)
continue
}
if tables, ok := result.Data.([]TableInfo); ok {
allTables = append(allTables, tables...)
}
}
return aggregateTableStats(allTables), nil
}
func getClusterServerDisks(ctx context.Context, config *db.IntegrationClickhouse, project *db.Project, topology []ClusterNode, ch *Client) ([]ServerDiskInfo, error) {
if ch.cloud {
return nil, nil
}
results, err := executeOnAllServers(ctx, config, topology, project, func(client *Client) (any, error) {
return client.GetDiskInfo(ctx)
})
if err != nil {
return nil, err
}
var servers []ServerDiskInfo
for _, result := range results {
server := ServerDiskInfo{
Addr: result.Addr,
}
if result.Error != nil {
klog.Warningf("failed to get disk info from server %s: %v", result.Addr, result.Error)
server.Error = result.Error.Error()
} else if disks, ok := result.Data.([]DiskInfo); ok {
server.Disks = disks
}
servers = append(servers, server)
}
return servers, nil
}
func aggregateTableStats(tables []TableInfo) []TableInfo {
agg := map[string]*TableInfo{}
for _, table := range tables {
var key string
switch {
case strings.HasPrefix(table.Table, "otel_logs"):
key = "logs"
case strings.HasPrefix(table.Table, "otel_traces"):
key = "traces"
case strings.HasPrefix(table.Table, "profiling_"):
key = "profiling"
case strings.HasPrefix(table.Table, "metrics"):
key = "metrics"
default:
continue
}
ti := agg[key]
if ti == nil {
ti = &TableInfo{
Table: key,
}
agg[key] = ti
}
ti.BytesOnDisk += table.BytesOnDisk
ti.DataUncompressedBytes += table.DataUncompressedBytes
if ti.TTLInfo == "" {
ti.TTLInfo = table.TTLInfo
}
if ti.TTLSeconds == nil && table.TTLSeconds != nil {
ti.TTLSeconds = table.TTLSeconds
}
if table.DataSince != nil {
if ti.DataSince == nil || table.DataSince.Before(*ti.DataSince) {
ti.DataSince = table.DataSince
}
}
if ti.BytesOnDisk > 0 {
ti.CompressionRatio = float64(ti.DataUncompressedBytes) / float64(ti.BytesOnDisk)
}
}
res := make([]TableInfo, 0, len(agg))
for _, ti := range agg {
res = append(res, *ti)
}
sort.Slice(res, func(i, j int) bool {
return res[i].Table < res[j].Table
})
return res
}
type Clients struct {
Clients []*Client
Error error
}
func (chs Clients) Close() {
for _, c := range chs.Clients {
_ = c.Close()
}
}
func GetClients(database *db.DB, project *db.Project, globalClickHouse *db.IntegrationClickhouse) (res Clients) {
var memberProjects []*db.Project
if project.Multicluster() {
projects, err := database.GetProjects()
if err != nil {
res.Error = err
return
}
for _, mp := range project.Settings.MemberProjects {
if p := projects[mp]; p != nil {
memberProjects = append(memberProjects, p)
}
}
} else {
memberProjects = []*db.Project{project}
}
for _, p := range memberProjects {
cfg := p.ClickHouseConfig(globalClickHouse)
if cfg == nil {
continue
}
c, err := NewClient(cfg, p)
if err != nil {
res.Close()
res = Clients{Error: err}
return
}
res.Clients = append(res.Clients, c)
}
return
}