Skip to content

Commit 1d0a4da

Browse files
committed
feat: cross-cluster ClickHouse support with configurable distributed table suffix
In multi-cluster ClickHouse deployments (multi-region, read/write separation), queries need to fan out across all clusters through a virtual cluster (e.g. "all"). This requires a second set of distributed tables with skip_unavailable_shards=1, using a different suffix and cluster name than the write-path tables. Changes: 1. Configurable distributed table suffix (CLICKHOUSE_DIST_SUFFIX): - New shared/distconfig package reads the suffix at startup - All hardcoded "_dist" replaced with distconfig.Suffix() - InitDistTableNames() re-registers table name mappings after init - Default remains "_dist" for full backward compatibility 2. Cross-cluster read-path DDL (CLICKHOUSE_READ_CLUSTER): - When set to a different cluster than CLUSTER_NAME, the DDL phase creates additional distributed tables for cross-cluster reads - New SQL templates: log_read_dist.sql, traces_read_dist.sql, profiles_read_dist.sql (version keys 7, 8, 9) - All read tables include SETTINGS skip_unavailable_shards = 1 3. Status attribute mapping: - TraceQL: { status = ok } now queries otel.status_code - Tempo tag values API: /api/search/tag/status/values maps correctly 4. Bug fix: leading space in health check table name " time_series_dist" Example multi-cluster configuration: CLUSTER_NAME=my_cluster # write cluster CLICKHOUSE_READ_CLUSTER=all # read cluster spanning all clusters CLICKHOUSE_DIST_SUFFIX=_all # suffix for read-path tables Closes #780
1 parent 3b1aa23 commit 1d0a4da

24 files changed

Lines changed: 351 additions & 31 deletions

File tree

cmd/gigapipe/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111
"github.com/metrico/qryn/v4/ctrl"
1212
"github.com/metrico/qryn/v4/reader"
1313
"github.com/metrico/qryn/v4/reader/utils/logger"
14+
"github.com/metrico/qryn/v4/reader/utils/tables"
15+
"github.com/metrico/qryn/v4/shared/distconfig"
1416
"github.com/metrico/qryn/v4/reader/utils/middleware"
1517
"github.com/metrico/qryn/v4/shared/commonroutes"
1618
"github.com/metrico/qryn/v4/view"
@@ -270,6 +272,8 @@ func main() {
270272
}
271273

272274
func start() {
275+
distconfig.Init()
276+
tables.InitDistTableNames()
273277
var configPaths []string
274278
if _, err := os.Stat(*appFlags.ConfigPath); err == nil {
275279
configPaths = append(configPaths, *appFlags.ConfigPath)

ctrl/qryn/maintenance/maintain.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package maintenance
33
import (
44
"context"
55
"fmt"
6+
"os"
67
"strings"
78
"time"
89

@@ -27,8 +28,13 @@ func upgradeDB(dbObject *config.ClokiBaseDataBase, logger logger.ILogger) error
2728
if dbObject.TTLDays == 0 {
2829
return fmt.Errorf("ttl_days should be set for node#%s", dbObject.Node)
2930
}
30-
return Update(conn, dbObject.Name, dbObject.ClusterName, mode, dbObject.TTLDays,
31-
dbObject.StoragePolicy, dbObject.SamplesOrdering, dbObject.SkipUnavailableShards, logger)
31+
readCluster := os.Getenv("CLICKHOUSE_READ_CLUSTER")
32+
readSuffix := os.Getenv("CLICKHOUSE_DIST_SUFFIX")
33+
if readSuffix == "" {
34+
readSuffix = "_dist"
35+
}
36+
return UpdateWithReadCluster(conn, dbObject.Name, dbObject.ClusterName, readCluster, readSuffix, mode,
37+
dbObject.TTLDays, dbObject.StoragePolicy, dbObject.SamplesOrdering, dbObject.SkipUnavailableShards, logger)
3238
}
3339

3440
func InitDB(dbObject *config.ClokiBaseDataBase, logger logger.ILogger) error {

ctrl/qryn/maintenance/rotate.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/ClickHouse/clickhouse-go/v2"
1111
"github.com/metrico/qryn/v4/ctrl/logger"
1212
"github.com/metrico/qryn/v4/ctrl/qryn/helputils"
13+
"github.com/metrico/qryn/v4/shared/distconfig"
1314
)
1415

1516
func getSetting(db clickhouse.Conn, dist bool, tp string, name string) (string, error) {
@@ -18,7 +19,7 @@ func getSetting(db clickhouse.Conn, dist bool, tp string, name string) (string,
1819
)
1920
settings := "settings"
2021
if dist {
21-
settings += "_dist"
22+
settings += distconfig.Suffix()
2223
}
2324
rows, err := db.Query(context.Background(),
2425
fmt.Sprintf(`SELECT argMax(value, inserted_at) as _value FROM %s WHERE fingerprint = $1

ctrl/qryn/maintenance/update.go

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/ClickHouse/clickhouse-go/v2"
1515
"github.com/metrico/qryn/v4/ctrl/logger"
1616
"github.com/metrico/qryn/v4/ctrl/qryn/sql"
17+
"github.com/metrico/qryn/v4/shared/distconfig"
1718
)
1819

1920
const (
@@ -23,6 +24,14 @@ const (
2324
)
2425

2526
func Update(db clickhouse.Conn, dbname string, clusterName string, mode int,
27+
ttlDays int, storagePolicy string, advancedSamplesOrdering string, skipUnavailableShards bool,
28+
logger logger.ILogger) error {
29+
return UpdateWithReadCluster(db, dbname, clusterName, "", "", mode,
30+
ttlDays, storagePolicy, advancedSamplesOrdering, skipUnavailableShards, logger)
31+
}
32+
33+
func UpdateWithReadCluster(db clickhouse.Conn, dbname string, clusterName string,
34+
readCluster string, readSuffix string, mode int,
2635
ttlDays int, storagePolicy string, advancedSamplesOrdering string, skipUnavailableShards bool,
2736
logger logger.ILogger) error {
2837
checkMode := func(m int) bool { return mode&m == m }
@@ -65,6 +74,26 @@ func Update(db clickhouse.Conn, dbname string, clusterName string, mode int,
6574
}
6675
}
6776

77+
// Cross-cluster read-path tables: when a separate read cluster is configured,
78+
// create distributed tables that aggregate queries across multiple clusters.
79+
if readCluster != "" && readCluster != clusterName && checkMode(CLUST_MODE_DISTRIBUTED) {
80+
err = updateReadDistScripts(db, dbname, clusterName, readCluster, readSuffix,
81+
7, sql.LogReadDistScript, logger)
82+
if err != nil {
83+
return err
84+
}
85+
err = updateReadDistScripts(db, dbname, clusterName, readCluster, readSuffix,
86+
8, sql.TracesReadDistScript, logger)
87+
if err != nil {
88+
return err
89+
}
90+
err = updateReadDistScripts(db, dbname, clusterName, readCluster, readSuffix,
91+
9, sql.ProfilesReadDistScript, logger)
92+
if err != nil {
93+
return err
94+
}
95+
}
96+
6897
err = Cleanup(db, clusterName, checkMode(CLUST_MODE_DISTRIBUTED), dbname, logger)
6998

7099
return err
@@ -110,6 +139,51 @@ func getDBExec(db clickhouse.Conn, env map[string]string, logger logger.ILogger)
110139
}
111140
}
112141

142+
func updateReadDistScripts(db clickhouse.Conn, dbname string, clusterName string,
143+
readCluster string, readSuffix string, k int64, file string, logger logger.ILogger) error {
144+
scripts, err := getSQLFile(file)
145+
if err != nil {
146+
return err
147+
}
148+
env := map[string]string{
149+
"DB": dbname,
150+
"CLUSTER": clusterName,
151+
"OnCluster": "ON CLUSTER `" + clusterName + "`",
152+
"READ_CLUSTER": readCluster,
153+
"READ_SUFFIX": readSuffix,
154+
}
155+
exec := getDBExec(db, env, logger)
156+
verTable := "ver" + distconfig.Suffix()
157+
var ver uint64 = 0
158+
if k >= 0 {
159+
rows, err := db.Query(context.Background(),
160+
fmt.Sprintf("SELECT max(ver) as ver FROM %s WHERE k = $1 FORMAT JSON", verTable), k)
161+
if err != nil {
162+
return err
163+
}
164+
for rows.Next() {
165+
err = rows.Scan(&ver)
166+
if err != nil {
167+
return err
168+
}
169+
}
170+
}
171+
for i := ver; i < uint64(len(scripts)); i++ {
172+
logger.Info(fmt.Sprintf("Upgrade read-dist v.%d to v.%d ", i, i+1))
173+
err = exec(scripts[i])
174+
if err != nil {
175+
logger.Error(scripts[i])
176+
return err
177+
}
178+
err = db.Exec(context.Background(), "INSERT INTO ver (k, ver) VALUES ($1, $2)", k, i+1)
179+
if err != nil {
180+
return err
181+
}
182+
logger.Info(fmt.Sprintf("Upgrade read-dist v.%d to v.%d ok", i, i+1))
183+
}
184+
return nil
185+
}
186+
113187
func updateScripts(db clickhouse.Conn, dbname string, clusterName string, k int64, file string, replicated bool,
114188
ttlDays int, storagePolicy string, advancedSamplesOrdering string, skipUnavailableShards bool, logger logger.ILogger) error {
115189
scripts, err := getSQLFile(file)
@@ -165,7 +239,7 @@ ENGINE=Distributed('{{.CLUSTER}}','{{.DB}}', 'ver', rand())`)
165239
if err != nil {
166240
return err
167241
}
168-
verTable = "ver_dist"
242+
verTable = "ver" + distconfig.Suffix()
169243
}
170244
var ver uint64 = 0
171245
if k >= 0 {

ctrl/qryn/sql/log_read_dist.sql

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
## Cross-cluster read-path distributed tables for logs/metrics
2+
## Used when CLICKHOUSE_READ_CLUSTER is set (multi-cluster query aggregation)
3+
## APPEND ONLY!!!!!
4+
5+
CREATE TABLE IF NOT EXISTS {{.DB}}.metrics_15s{{.READ_SUFFIX}} {{.OnCluster}} (
6+
`fingerprint` UInt64,
7+
`timestamp_ns` Int64 CODEC(DoubleDelta),
8+
`last` AggregateFunction(argMax, Float64, Int64),
9+
`max` SimpleAggregateFunction(max, Float64),
10+
`min` SimpleAggregateFunction(min, Float64),
11+
`count` AggregateFunction(count),
12+
`sum` SimpleAggregateFunction(sum, Float64),
13+
`bytes` SimpleAggregateFunction(sum, Float64)
14+
) ENGINE = Distributed('{{.READ_CLUSTER}}', '{{.DB}}', 'metrics_15s', fingerprint) SETTINGS skip_unavailable_shards = 1;
15+
16+
CREATE TABLE IF NOT EXISTS {{.DB}}.samples_v3{{.READ_SUFFIX}} {{.OnCluster}} (
17+
`fingerprint` UInt64,
18+
`timestamp_ns` Int64 CODEC(DoubleDelta),
19+
`value` Float64 CODEC(Gorilla),
20+
`string` String
21+
) ENGINE = Distributed('{{.READ_CLUSTER}}','{{.DB}}', 'samples_v3', fingerprint) SETTINGS skip_unavailable_shards = 1;
22+
23+
CREATE TABLE IF NOT EXISTS {{.DB}}.time_series{{.READ_SUFFIX}} {{.OnCluster}} (
24+
`date` Date,
25+
`fingerprint` UInt64,
26+
`labels` String,
27+
`name` String
28+
) ENGINE = Distributed('{{.READ_CLUSTER}}','{{.DB}}', 'time_series', fingerprint) SETTINGS skip_unavailable_shards = 1;
29+
30+
CREATE TABLE IF NOT EXISTS {{.DB}}.settings{{.READ_SUFFIX}} {{.OnCluster}} (
31+
`fingerprint` UInt64,
32+
`type` String,
33+
`name` String,
34+
`value` String,
35+
`inserted_at` DateTime64(9, 'UTC')
36+
) ENGINE = Distributed('{{.READ_CLUSTER}}','{{.DB}}', 'settings', rand()) SETTINGS skip_unavailable_shards = 1;
37+
38+
CREATE TABLE IF NOT EXISTS {{.DB}}.time_series_gin{{.READ_SUFFIX}} {{.OnCluster}} (
39+
date Date,
40+
key String,
41+
val String,
42+
fingerprint UInt64
43+
) ENGINE = Distributed('{{.READ_CLUSTER}}','{{.DB}}', 'time_series_gin', rand()) SETTINGS skip_unavailable_shards = 1;
44+
45+
ALTER TABLE {{.DB}}.metrics_15s{{.READ_SUFFIX}} {{.OnCluster}} ADD COLUMN IF NOT EXISTS `type` UInt8;
46+
47+
ALTER TABLE {{.DB}}.samples_v3{{.READ_SUFFIX}} {{.OnCluster}} ADD COLUMN IF NOT EXISTS `type` UInt8;
48+
49+
ALTER TABLE {{.DB}}.time_series{{.READ_SUFFIX}} {{.OnCluster}} ADD COLUMN IF NOT EXISTS `type` UInt8;
50+
51+
ALTER TABLE {{.DB}}.time_series_gin{{.READ_SUFFIX}} {{.OnCluster}} ADD COLUMN IF NOT EXISTS `type` UInt8;
52+
53+
CREATE TABLE IF NOT EXISTS {{.DB}}.patterns{{.READ_SUFFIX}} {{.OnCluster}}(
54+
timestamp_10m UInt32,
55+
fingerprint UInt64,
56+
timestamp_s UInt32,
57+
tokens Array(String),
58+
classes Array(UInt32),
59+
overall_cost UInt32,
60+
generalized_cost UInt32,
61+
samples_count UInt32,
62+
pattern_id UInt64,
63+
iteration_id UInt64
64+
) ENGINE = Distributed('{{.READ_CLUSTER}}','{{.DB}}', 'patterns', fingerprint) SETTINGS skip_unavailable_shards = 1;
65+
66+
CREATE TABLE IF NOT EXISTS {{.DB}}.ver{{.READ_SUFFIX}} {{.OnCluster}} (
67+
k UInt64,
68+
ver UInt64
69+
) ENGINE = Distributed('{{.READ_CLUSTER}}','{{.DB}}', 'ver', rand()) SETTINGS skip_unavailable_shards = 1;
70+
71+
ALTER TABLE {{.DB}}.time_series{{.READ_SUFFIX}} {{.OnCluster}}
72+
ADD COLUMN IF NOT EXISTS metadata String DEFAULT '';
73+
74+
ALTER TABLE {{.DB}}.time_series{{.READ_SUFFIX}} {{.OnCluster}}
75+
ADD COLUMN IF NOT EXISTS updated_at_ns Int64 DEFAULT toUnixTimestamp64Nano(now64(9));
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
## Cross-cluster read-path distributed tables for profiles
2+
## Used when CLICKHOUSE_READ_CLUSTER is set (multi-cluster query aggregation)
3+
## APPEND ONLY!!!!!
4+
5+
CREATE TABLE IF NOT EXISTS {{.DB}}.profiles{{.READ_SUFFIX}} {{.OnCluster}} (
6+
timestamp_ns UInt64,
7+
fingerprint UInt64,
8+
type_id LowCardinality(String),
9+
service_name LowCardinality(String),
10+
duration_ns UInt64,
11+
payload_type LowCardinality(String),
12+
payload String,
13+
values_agg Array(Tuple(String, Int64, Int32))
14+
) ENGINE = Distributed('{{.READ_CLUSTER}}','{{.DB}}','profiles', fingerprint) SETTINGS skip_unavailable_shards = 1;
15+
16+
CREATE TABLE IF NOT EXISTS {{.DB}}.profiles_series{{.READ_SUFFIX}} {{.OnCluster}} (
17+
date Date,
18+
type_id LowCardinality(String),
19+
service_name LowCardinality(String),
20+
fingerprint UInt64 CODEC(DoubleDelta, ZSTD(1)),
21+
tags Array(Tuple(String, String)) CODEC(ZSTD(1))
22+
) ENGINE = Distributed('{{.READ_CLUSTER}}','{{.DB}}','profiles_series',fingerprint) SETTINGS skip_unavailable_shards = 1;
23+
24+
CREATE TABLE IF NOT EXISTS {{.DB}}.profiles_series_gin{{.READ_SUFFIX}} {{.OnCluster}} (
25+
date Date,
26+
key String,
27+
val String,
28+
type_id LowCardinality(String),
29+
service_name LowCardinality(String),
30+
fingerprint UInt64 CODEC(DoubleDelta, ZSTD(1))
31+
) ENGINE = Distributed('{{.READ_CLUSTER}}','{{.DB}}','profiles_series_gin',fingerprint) SETTINGS skip_unavailable_shards = 1;
32+
33+
CREATE TABLE IF NOT EXISTS {{.DB}}.profiles_series_keys{{.READ_SUFFIX}} {{.OnCluster}} (
34+
date Date,
35+
key String,
36+
val String,
37+
val_id UInt64
38+
) ENGINE = Distributed('{{.READ_CLUSTER}}','{{.DB}}','profiles_series_keys', rand()) SETTINGS skip_unavailable_shards = 1;
39+
40+
ALTER TABLE {{.DB}}.profiles{{.READ_SUFFIX}} {{.OnCluster}}
41+
ADD COLUMN IF NOT EXISTS `tree` Array(Tuple(UInt64, UInt64, UInt64, Array(Tuple(String, Int64, Int64)))),
42+
ADD COLUMN IF NOT EXISTS `functions` Array(Tuple(UInt64, String));
43+
44+
ALTER TABLE {{.DB}}.profiles{{.READ_SUFFIX}} {{.OnCluster}}
45+
ADD COLUMN IF NOT EXISTS `sample_types_units` Array(Tuple(String, String));
46+
47+
ALTER TABLE {{.DB}}.profiles_series{{.READ_SUFFIX}} {{.OnCluster}}
48+
ADD COLUMN IF NOT EXISTS `sample_types_units` Array(Tuple(String, String));
49+
50+
ALTER TABLE {{.DB}}.profiles_series_gin{{.READ_SUFFIX}} {{.OnCluster}}
51+
ADD COLUMN IF NOT EXISTS `sample_types_units` Array(Tuple(String, String));

ctrl/qryn/sql/sql.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,23 @@ var LogScript string
88
//go:embed log_dist.sql
99
var LogDistScript string
1010

11+
//go:embed log_read_dist.sql
12+
var LogReadDistScript string
13+
1114
//go:embed traces.sql
1215
var TracesScript string
1316

1417
//go:embed traces_dist.sql
1518
var TracesDistScript string
1619

20+
//go:embed traces_read_dist.sql
21+
var TracesReadDistScript string
22+
1723
//go:embed profiles.sql
1824
var ProfilesScript string
1925

2026
//go:embed profiles_dist.sql
2127
var ProfilesDistScript string
28+
29+
//go:embed profiles_read_dist.sql
30+
var ProfilesReadDistScript string

ctrl/qryn/sql/traces_read_dist.sql

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
## Cross-cluster read-path distributed tables for traces
2+
## Used when CLICKHOUSE_READ_CLUSTER is set (multi-cluster query aggregation)
3+
## APPEND ONLY!!!!!
4+
5+
CREATE TABLE IF NOT EXISTS {{.DB}}.tempo_traces_kv{{.READ_SUFFIX}} {{.OnCluster}} (
6+
oid String,
7+
date Date,
8+
key String,
9+
val_id String,
10+
val String
11+
) ENGINE = Distributed('{{.READ_CLUSTER}}','{{.DB}}', 'tempo_traces_kv', sipHash64(oid, key)) SETTINGS skip_unavailable_shards = 1;
12+
13+
CREATE TABLE IF NOT EXISTS {{.DB}}.tempo_traces{{.READ_SUFFIX}} {{.OnCluster}} (
14+
oid String,
15+
trace_id FixedString(16),
16+
span_id FixedString(8),
17+
parent_id String,
18+
name String,
19+
timestamp_ns Int64 CODEC(DoubleDelta),
20+
duration_ns Int64,
21+
service_name String,
22+
payload_type Int8,
23+
payload String
24+
) ENGINE = Distributed('{{.READ_CLUSTER}}','{{.DB}}', 'tempo_traces', sipHash64(oid, trace_id)) SETTINGS skip_unavailable_shards = 1;
25+
26+
CREATE TABLE IF NOT EXISTS {{.DB}}.tempo_traces_attrs_gin{{.READ_SUFFIX}} {{.OnCluster}} (
27+
oid String,
28+
date Date,
29+
key String,
30+
val String,
31+
trace_id FixedString(16),
32+
span_id FixedString(8),
33+
timestamp_ns Int64,
34+
duration Int64
35+
) ENGINE = Distributed('{{.READ_CLUSTER}}','{{.DB}}', 'tempo_traces_attrs_gin', sipHash64(oid, trace_id)) SETTINGS skip_unavailable_shards = 1;

reader/controller/tempo.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,9 @@ func (t *TempoController) ValuesV2(w http.ResponseWriter, r *http.Request) {
251251
timespan[i] = time.Unix(iT, 0)
252252
}
253253
tag := mux.Vars(r)["tag"]
254+
if tag == "status" {
255+
tag = "otel.status_code"
256+
}
254257

255258
limit := 2000
256259
if r.URL.Query().Get("limit") != "" {
@@ -302,6 +305,9 @@ func (t *TempoController) Values(w http.ResponseWriter, r *http.Request) {
302305
return
303306
}
304307
tag := mux.Vars(r)["tag"]
308+
if tag == "status" {
309+
tag = "otel.status_code"
310+
}
305311
cRes, err := t.Service.Values(internalCtx, tag)
306312
if err != nil {
307313
PromError(500, err.Error(), w)

reader/logql/logql_transpiler/clickhouse_planner/planner_labels_detect.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package clickhouse_planner
33
import (
44
"github.com/metrico/qryn/v4/reader/logql/logql_transpiler/shared"
55
sql "github.com/metrico/qryn/v4/reader/utils/sql_select"
6+
"github.com/metrico/qryn/v4/shared/distconfig"
67
)
78

89
type DetectLabelsPlanner struct {
@@ -13,7 +14,7 @@ type DetectLabelsPlanner struct {
1314
func (d *DetectLabelsPlanner) Process(ctx *shared.PlannerContext) (sql.ISelect, error) {
1415
from := ctx.TimeSeriesGinTableName
1516
if ctx.IsCluster {
16-
from += "_dist"
17+
from += distconfig.Suffix()
1718
}
1819

1920
req := sql.NewSelect().

0 commit comments

Comments
 (0)