@oneuptime/common 11.0.14 → 11.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel.ts +23 -0
  2. package/Models/AnalyticsModels/AuditLog.ts +2 -0
  3. package/Models/AnalyticsModels/ExceptionInstance.ts +5 -0
  4. package/Models/AnalyticsModels/Log.ts +10 -0
  5. package/Models/AnalyticsModels/Metric.ts +7 -0
  6. package/Models/AnalyticsModels/MetricBaselineHourly.ts +5 -0
  7. package/Models/AnalyticsModels/MetricItemAggMV1m.ts +5 -0
  8. package/Models/AnalyticsModels/MetricItemAggMV1mByHostV2.ts +2 -0
  9. package/Models/AnalyticsModels/MonitorLog.ts +2 -0
  10. package/Models/AnalyticsModels/Profile.ts +9 -0
  11. package/Models/AnalyticsModels/ProfileSample.ts +2 -0
  12. package/Models/AnalyticsModels/Span.ts +5 -0
  13. package/Server/EnvironmentConfig.ts +25 -0
  14. package/Server/Services/AnalyticsDatabaseService.ts +27 -14
  15. package/Server/Utils/AnalyticsDatabase/ClusterConfig.ts +177 -0
  16. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +101 -21
  17. package/Server/Utils/CodeRepository/CodeRepository.ts +8 -0
  18. package/Server/Utils/Execute.ts +9 -0
  19. package/Server/Utils/Monitor/MonitorTemplateUtil.ts +4 -0
  20. package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +8 -3
  21. package/Tests/Server/Utils/AnalyticsDatabase/ClusterAwareSchema.test.ts +296 -0
  22. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +5 -3
  23. package/Types/Monitor/ExternalStatusPageMonitor/ExternalStatusPageMonitorResponse.ts +5 -0
  24. package/Types/Monitor/ExternalStatusPageProviderType.ts +1 -0
  25. package/Types/Monitor/MonitorCriteriaInstance.ts +34 -14
  26. package/Types/Monitor/MonitorStepExternalStatusPageMonitor.ts +5 -1
  27. package/UI/Components/MonitorTemplateVariables/TemplateVariablesCatalog.ts +14 -1
  28. package/build/dist/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel.js +8 -0
  29. package/build/dist/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel.js.map +1 -1
  30. package/build/dist/Models/AnalyticsModels/AuditLog.js +2 -0
  31. package/build/dist/Models/AnalyticsModels/AuditLog.js.map +1 -1
  32. package/build/dist/Models/AnalyticsModels/ExceptionInstance.js +5 -0
  33. package/build/dist/Models/AnalyticsModels/ExceptionInstance.js.map +1 -1
  34. package/build/dist/Models/AnalyticsModels/Log.js +10 -0
  35. package/build/dist/Models/AnalyticsModels/Log.js.map +1 -1
  36. package/build/dist/Models/AnalyticsModels/Metric.js +7 -0
  37. package/build/dist/Models/AnalyticsModels/Metric.js.map +1 -1
  38. package/build/dist/Models/AnalyticsModels/MetricBaselineHourly.js +5 -0
  39. package/build/dist/Models/AnalyticsModels/MetricBaselineHourly.js.map +1 -1
  40. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1m.js +5 -0
  41. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1m.js.map +1 -1
  42. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByHostV2.js +2 -0
  43. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByHostV2.js.map +1 -1
  44. package/build/dist/Models/AnalyticsModels/MonitorLog.js +2 -0
  45. package/build/dist/Models/AnalyticsModels/MonitorLog.js.map +1 -1
  46. package/build/dist/Models/AnalyticsModels/Profile.js +9 -0
  47. package/build/dist/Models/AnalyticsModels/Profile.js.map +1 -1
  48. package/build/dist/Models/AnalyticsModels/ProfileSample.js +2 -0
  49. package/build/dist/Models/AnalyticsModels/ProfileSample.js.map +1 -1
  50. package/build/dist/Models/AnalyticsModels/Span.js +5 -0
  51. package/build/dist/Models/AnalyticsModels/Span.js.map +1 -1
  52. package/build/dist/Server/EnvironmentConfig.js +21 -0
  53. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  54. package/build/dist/Server/Services/AnalyticsDatabaseService.js +22 -14
  55. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  56. package/build/dist/Server/Utils/AnalyticsDatabase/ClusterConfig.js +144 -0
  57. package/build/dist/Server/Utils/AnalyticsDatabase/ClusterConfig.js.map +1 -0
  58. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +68 -15
  59. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  60. package/build/dist/Server/Utils/CodeRepository/CodeRepository.js +2 -6
  61. package/build/dist/Server/Utils/CodeRepository/CodeRepository.js.map +1 -1
  62. package/build/dist/Server/Utils/Execute.js +3 -1
  63. package/build/dist/Server/Utils/Execute.js.map +1 -1
  64. package/build/dist/Server/Utils/Monitor/MonitorTemplateUtil.js +4 -0
  65. package/build/dist/Server/Utils/Monitor/MonitorTemplateUtil.js.map +1 -1
  66. package/build/dist/Types/Monitor/ExternalStatusPageProviderType.js +1 -0
  67. package/build/dist/Types/Monitor/ExternalStatusPageProviderType.js.map +1 -1
  68. package/build/dist/Types/Monitor/MonitorCriteriaInstance.js +34 -14
  69. package/build/dist/Types/Monitor/MonitorCriteriaInstance.js.map +1 -1
  70. package/build/dist/Types/Monitor/MonitorStepExternalStatusPageMonitor.js +3 -0
  71. package/build/dist/Types/Monitor/MonitorStepExternalStatusPageMonitor.js.map +1 -1
  72. package/build/dist/UI/Components/MonitorTemplateVariables/TemplateVariablesCatalog.js +13 -1
  73. package/build/dist/UI/Components/MonitorTemplateVariables/TemplateVariablesCatalog.js.map +1 -1
  74. package/package.json +1 -1
@@ -58,6 +58,20 @@ export default class AnalyticsBaseModel extends CommonModel {
58
58
  enableWorkflowOn?: EnableWorkflowOn | undefined;
59
59
  enableRealtimeEventsOn?: EnableRealtimeEventsOn | undefined;
60
60
  partitionKey: string;
61
+ /*
62
+ * Sharding-key EXPRESSION for the Distributed table created in cluster
63
+ * mode (e.g. "cityHash64(traceId)"). Pick a HIGH-cardinality column so a
64
+ * single big project/tenant spreads evenly across shards, and co-locate
65
+ * what you read/aggregate together (a trace's spans, a metric series).
66
+ * Correctness never depends on this — the Distributed table fans reads out
67
+ * to every shard regardless — it is purely a distribution/locality choice.
68
+ * Falls back to cityHash64(projectId) when unset.
69
+ *
70
+ * MUST evaluate to a NON-nullable integer: ClickHouse rejects a Distributed
71
+ * sharding expression of type Nullable(...). Wrap nullable columns, e.g.
72
+ * cityHash64(ifNull(traceId, '')).
73
+ */
74
+ shardingKey?: string | undefined;
61
75
  tableSettings?: string | undefined;
62
76
  projections?: Array<Projection> | undefined;
63
77
  materializedViews?: Array<MaterializedView> | undefined;
@@ -172,6 +186,7 @@ export default class AnalyticsBaseModel extends CommonModel {
172
186
  this.isMasterAdminApiDocs = data.isMasterAdminApiDocs || false;
173
187
  this.enableRealtimeEventsOn = data.enableRealtimeEventsOn;
174
188
  this.partitionKey = data.partitionKey;
189
+ this.shardingKey = data.shardingKey;
175
190
  this.tableSettings = data.tableSettings;
176
191
  this.projections = data.projections || [];
177
192
  this.materializedViews = data.materializedViews || [];
@@ -250,6 +265,14 @@ export default class AnalyticsBaseModel extends CommonModel {
250
265
  this._partitionKey = v;
251
266
  }
252
267
 
268
+ private _shardingKey: string | undefined = undefined;
269
+ public get shardingKey(): string | undefined {
270
+ return this._shardingKey;
271
+ }
272
+ public set shardingKey(v: string | undefined) {
273
+ this._shardingKey = v;
274
+ }
275
+
253
276
  private _tableSettings: string | undefined = undefined;
254
277
  public get tableSettings(): string | undefined {
255
278
  return this._tableSettings;
@@ -289,6 +289,8 @@ export default class AuditLog extends AnalyticsBaseModel {
289
289
  sortKeys: ["projectId", "createdAt", "resourceType", "resourceId"],
290
290
  primaryKeys: ["projectId", "createdAt"],
291
291
  partitionKey: "toYYYYMM(createdAt)",
292
+ // Shard by the audited resource so its history co-locates; spreads across shards.
293
+ shardingKey: "cityHash64(projectId, resourceId)",
292
294
  tableSettings:
293
295
  "ttl_only_drop_parts = 1, non_replicated_deduplication_window = 10000",
294
296
  ttlExpression: "retentionDate DELETE",
@@ -802,6 +802,11 @@ export default class ExceptionInstance extends AnalyticsBaseModel {
802
802
  sortKeys: ["projectId", "time", "primaryEntityId", "fingerprint"],
803
803
  primaryKeys: ["projectId", "time", "primaryEntityId", "fingerprint"],
804
804
  partitionKey: "toYYYYMMDD(time)",
805
+ /*
806
+ * Shard by fingerprint so all occurrences of one exception co-locate and a
807
+ * big project's distinct exceptions spread across shards.
808
+ */
809
+ shardingKey: "cityHash64(projectId, fingerprint)",
805
810
  tableSettings:
806
811
  "ttl_only_drop_parts = 1, non_replicated_deduplication_window = 10000",
807
812
  ttlExpression: "retentionDate DELETE",
@@ -697,6 +697,16 @@ export default class Log extends AnalyticsBaseModel {
697
697
  sortKeys: ["projectId", "time", "primaryEntityId"],
698
698
  primaryKeys: ["projectId", "time", "primaryEntityId"],
699
699
  partitionKey: "toYYYYMMDD(time)",
700
+ /*
701
+ * Shard by (projectId, primaryEntityId, time). traceId is NOT used: it is
702
+ * Nullable on logs AND most logs have no trace, so a traceId hash would pile
703
+ * the majority onto one shard. These three columns are always present
704
+ * (non-nullable), and including the high-entropy `time` means even a single
705
+ * very-high-volume service spreads evenly across all shards — no hotspot.
706
+ * (Trace-scoped log reads scatter-gather, which is cheap via the traceId
707
+ * bloom-filter skip index and rare here.)
708
+ */
709
+ shardingKey: "cityHash64(projectId, primaryEntityId, time)",
700
710
  tableSettings:
701
711
  "ttl_only_drop_parts = 1, non_replicated_deduplication_window = 10000",
702
712
  ttlExpression: "retentionDate DELETE",
@@ -1209,6 +1209,13 @@ export default class Metric extends AnalyticsBaseModel {
1209
1209
  sortKeys: ["projectId", "name", "primaryEntityId", "time"],
1210
1210
  primaryKeys: ["projectId", "name", "primaryEntityId", "time"],
1211
1211
  partitionKey: "toYYYYMMDD(time)",
1212
+ /*
1213
+ * Shard by the metric series (projectId, name, primaryEntityId): spreads a
1214
+ * big project's many series across shards, and co-locates each series on
1215
+ * one shard so the minute/hour rollup MVs aggregate per-shard cleanly
1216
+ * (the MV GROUP BY starts with these columns).
1217
+ */
1218
+ shardingKey: "cityHash64(projectId, name, primaryEntityId)",
1212
1219
  tableSettings:
1213
1220
  "ttl_only_drop_parts = 1, non_replicated_deduplication_window = 10000",
1214
1221
  ttlExpression: "retentionDate DELETE",
@@ -226,6 +226,11 @@ GROUP BY projectId, name, primaryEntityId, day, hourOfWeek`,
226
226
  "day",
227
227
  ],
228
228
  partitionKey: "toYYYYMM(day)",
229
+ /*
230
+ * Match the source Metric sharding (the series) so each series' baseline
231
+ * states stay on a single shard.
232
+ */
233
+ shardingKey: "cityHash64(projectId, name, primaryEntityId)",
229
234
  tableSettings:
230
235
  "ttl_only_drop_parts = 1, non_replicated_deduplication_window = 10000",
231
236
  ttlExpression: "day + INTERVAL 90 DAY",
@@ -164,6 +164,11 @@ GROUP BY projectId, name, primaryEntityId, bucketTime`,
164
164
  sortKeys: ["projectId", "name", "primaryEntityId", "bucketTime"],
165
165
  primaryKeys: ["projectId", "name", "primaryEntityId", "bucketTime"],
166
166
  partitionKey: "toYYYYMM(bucketTime)",
167
+ /*
168
+ * Match the source Metric sharding (the series) so each series' rollup
169
+ * states stay on a single shard — no cross-shard partial-state merge.
170
+ */
171
+ shardingKey: "cityHash64(projectId, name, primaryEntityId)",
167
172
  tableSettings:
168
173
  "ttl_only_drop_parts = 1, non_replicated_deduplication_window = 10000",
169
174
  ttlExpression: "retentionDate DELETE",
@@ -171,6 +171,8 @@ GROUP BY projectId, name, hostEntityKey, bucketTime`,
171
171
  sortKeys: ["projectId", "name", "hostEntityKey", "bucketTime"],
172
172
  primaryKeys: ["projectId", "name", "hostEntityKey", "bucketTime"],
173
173
  partitionKey: "toYYYYMM(bucketTime)",
174
+ // Align with this MV's GROUP BY (projectId, name, hostEntityKey).
175
+ shardingKey: "cityHash64(projectId, name, hostEntityKey)",
174
176
  tableSettings:
175
177
  "ttl_only_drop_parts = 1, non_replicated_deduplication_window = 10000",
176
178
  ttlExpression: "retentionDate DELETE",
@@ -194,6 +194,8 @@ export default class MonitorLog extends AnalyticsBaseModel {
194
194
  sortKeys: ["projectId", "time", "monitorId"],
195
195
  primaryKeys: ["projectId", "time", "monitorId"],
196
196
  partitionKey: "toYYYYMMDD(time)",
197
+ // Shard by monitorId so a monitor's logs co-locate; spreads across shards.
198
+ shardingKey: "cityHash64(monitorId)",
197
199
  tableSettings:
198
200
  "ttl_only_drop_parts = 1, non_replicated_deduplication_window = 10000",
199
201
  ttlExpression: "retentionDate DELETE",
@@ -809,6 +809,15 @@ export default class Profile extends AnalyticsBaseModel {
809
809
  sortKeys: ["projectId", "startTime", "primaryEntityId", "profileType"],
810
810
  primaryKeys: ["projectId", "startTime", "primaryEntityId", "profileType"],
811
811
  partitionKey: "toYYYYMMDD(startTime)",
812
+ /*
813
+ * Shard by (projectId, primaryEntityId, startTime). All three are
814
+ * non-nullable. Without startTime, a single heavily-profiled service
815
+ * would pile all its profiles onto one shard; the high-entropy startTime
816
+ * spreads them evenly (same reasoning as Log). A profile's samples live in
817
+ * ProfileSample (sharded by profileId), so co-locating Profile rows by
818
+ * service buys little — even spread wins here.
819
+ */
820
+ shardingKey: "cityHash64(projectId, primaryEntityId, startTime)",
812
821
  tableSettings:
813
822
  "ttl_only_drop_parts = 1, non_replicated_deduplication_window = 10000",
814
823
  ttlExpression: "retentionDate DELETE",
@@ -665,6 +665,8 @@ export default class ProfileSample extends AnalyticsBaseModel {
665
665
  "stacktraceHash",
666
666
  ],
667
667
  partitionKey: "toYYYYMMDD(time)",
668
+ // Shard by profileId so all samples of a profile co-locate on one shard.
669
+ shardingKey: "cityHash64(profileId)",
668
670
  tableSettings:
669
671
  "ttl_only_drop_parts = 1, non_replicated_deduplication_window = 10000",
670
672
  ttlExpression: "retentionDate DELETE",
@@ -996,6 +996,11 @@ export default class Span extends AnalyticsBaseModel {
996
996
  sortKeys: ["projectId", "startTime", "primaryEntityId", "traceId"],
997
997
  primaryKeys: ["projectId", "startTime", "primaryEntityId", "traceId"],
998
998
  partitionKey: "toYYYYMMDD(startTime)",
999
+ /*
1000
+ * Shard by traceId: high-cardinality so a big project spreads evenly, and
1001
+ * it keeps every span of a trace on one shard for a fast single-trace view.
1002
+ */
1003
+ shardingKey: "cityHash64(traceId)",
999
1004
  tableSettings:
1000
1005
  "ttl_only_drop_parts = 1, non_replicated_deduplication_window = 10000",
1001
1006
  ttlExpression: "retentionDate DELETE",
@@ -426,6 +426,31 @@ export const MaxClickhouseIngestConnections: number = parseInt(
426
426
  10,
427
427
  );
428
428
 
429
+ /*
430
+ * Cluster name. The analytics schema ALWAYS runs as a sharded + replicated
431
+ * cluster (Distributed tables over local ReplicatedMergeTree, `ON CLUSTER
432
+ * '<name>'`); a single node is just a 1-shard/1-replica cluster backed by an
433
+ * embedded Keeper. The name must match the cluster defined in the ClickHouse
434
+ * config / ClickHouseInstallation; it defaults to 'oneuptime' (what the bundled
435
+ * StatefulSet config and the Altinity operator both create).
436
+ *
437
+ * NOTE: the live, test-toggleable readers live in
438
+ * Common/Server/Utils/AnalyticsDatabase/ClusterConfig.ts (which reads
439
+ * process.env directly). These consts mirror the same keys/defaults and exist
440
+ * so the env surface is discoverable here alongside the other CLICKHOUSE_* vars.
441
+ */
442
+ export const ClickhouseClusterName: string =
443
+ process.env["CLICKHOUSE_CLUSTER_NAME"] || "oneuptime";
444
+
445
+ /*
446
+ * Optional GLOBAL override of the Distributed sharding-key expression. Empty by
447
+ * default, which means each model's own `shardingKey` is used (e.g.
448
+ * cityHash64(traceId) for spans, the series tuple for metrics). Set this to
449
+ * force one expression across all tables.
450
+ */
451
+ export const ClickhouseShardingKeyOverride: string =
452
+ process.env["CLICKHOUSE_SHARDING_KEY"] || "";
453
+
429
454
  export const GitSha: string = process.env["GIT_SHA"] || "unknown";
430
455
 
431
456
  export const AppVersion: string = process.env["APP_VERSION"] || "unknown";
@@ -29,6 +29,10 @@ import UpdateBy from "../Types/AnalyticsDatabase/UpdateBy";
29
29
  import { SQL, Statement } from "../Utils/AnalyticsDatabase/Statement";
30
30
  import StatementGenerator from "../Utils/AnalyticsDatabase/StatementGenerator";
31
31
  import { getQuerySettings } from "../Utils/AnalyticsDatabase/QuerySettingsHelper";
32
+ import {
33
+ getStorageTableName,
34
+ onClusterClause,
35
+ } from "../Utils/AnalyticsDatabase/ClusterConfig";
32
36
  import logger, { LogAttributes } from "../Utils/Logger";
33
37
  import Realtime from "../Utils/Realtime";
34
38
  import StreamUtil from "../Utils/Stream";
@@ -472,7 +476,8 @@ export default class AnalyticsDatabaseService<
472
476
  }
473
477
 
474
478
  public async doesColumnExist(columnName: string): Promise<boolean> {
475
- const tableName: string = this.model.tableName;
479
+ // Columns live on the physical (local) storage table in cluster mode.
480
+ const tableName: string = getStorageTableName(this.model.tableName);
476
481
  const result: { data: Array<JSONObject> } = await (
477
482
  await this.executeQuery(
478
483
  `SELECT count() as cnt FROM system.columns WHERE database = currentDatabase() AND table = '${tableName}' AND name = '${columnName}'`,
@@ -485,7 +490,7 @@ export default class AnalyticsDatabaseService<
485
490
  }
486
491
 
487
492
  public async getColumnCodec(columnName: string): Promise<string> {
488
- const tableName: string = this.model.tableName;
493
+ const tableName: string = getStorageTableName(this.model.tableName);
489
494
  const result: { data: Array<JSONObject> } = await (
490
495
  await this.executeQuery(
491
496
  `SELECT compression_codec FROM system.columns WHERE database = currentDatabase() AND table = '${tableName}' AND name = '${columnName}'`,
@@ -508,7 +513,7 @@ export default class AnalyticsDatabaseService<
508
513
  * re-state a column's type in a MODIFY COLUMN without guessing it.
509
514
  */
510
515
  public async getColumnDatabaseType(columnName: string): Promise<string> {
511
- const tableName: string = this.model.tableName;
516
+ const tableName: string = getStorageTableName(this.model.tableName);
512
517
  const result: { data: Array<JSONObject> } = await (
513
518
  await this.executeQuery(
514
519
  `SELECT type FROM system.columns WHERE database = currentDatabase() AND table = '${tableName}' AND name = '${columnName}'`,
@@ -530,7 +535,11 @@ export default class AnalyticsDatabaseService<
530
535
  codec: string;
531
536
  expectedCodecValue: string;
532
537
  }): Promise<void> {
533
- const tableName: string = this.model.tableName;
538
+ /*
539
+ * MODIFY COLUMN on the local table; ReplicatedMergeTree fans the codec
540
+ * change out to the other replicas through Keeper.
541
+ */
542
+ const tableName: string = getStorageTableName(this.model.tableName);
534
543
  const currentCodec: string = await this.getColumnCodec(data.columnName);
535
544
 
536
545
  if (currentCodec === data.expectedCodecValue) {
@@ -1294,19 +1303,23 @@ export default class AnalyticsDatabaseService<
1294
1303
  );
1295
1304
 
1296
1305
  /*
1297
- * Use ClickHouse lightweight deletes (`DELETE FROM`) rather than
1298
- * `ALTER TABLE DELETE`. The latter creates an async mutation that
1299
- * rewrites whole parts and is bounded by `number_of_mutations_to_throw`
1300
- * (default 1000). Customers with chatty state transitions hit that
1301
- * ceiling and every subsequent delete fails with TOO_MANY_MUTATIONS.
1302
- * Lightweight deletes mark rows via the hidden `_row_exists` column
1303
- * and are reconciled during normal merges, so they don't accumulate
1304
- * in the mutations queue.
1306
+ * Lightweight `DELETE FROM` cannot target a Distributed table and does not
1307
+ * accept `ON CLUSTER`, so deletes are an `ALTER TABLE <local> ON CLUSTER …
1308
+ * DELETE` mutation dispatched to every shard (and replicated within each
1309
+ * shard via Keeper). ALTER ... DELETE mutations are bounded by
1310
+ * `number_of_mutations_to_throw` (default 1000) but deletes are rare here
1311
+ * (retention is handled by TTL), so the queue does not accumulate.
1305
1312
  */
1306
1313
  /* eslint-disable prettier/prettier */
1314
+ const localTableName: string = getStorageTableName(this.model.tableName);
1307
1315
  const statement: Statement = SQL`
1308
- DELETE FROM ${databaseName}.${this.model.tableName}
1309
- WHERE TRUE `.append(whereStatement);
1316
+ ALTER TABLE ${databaseName}.${localTableName}`
1317
+ .append(onClusterClause())
1318
+ .append(
1319
+ SQL`
1320
+ DELETE WHERE TRUE `,
1321
+ )
1322
+ .append(whereStatement);
1310
1323
 
1311
1324
  logger.debug(`${this.model.tableName} Delete Statement`, {
1312
1325
  tableName: this.model.tableName,
@@ -0,0 +1,177 @@
1
+ import AnalyticsTableEngine from "../../../Types/AnalyticsDatabase/AnalyticsTableEngine";
2
+
3
+ /*
4
+ * ClickHouse cluster helpers.
5
+ *
6
+ * OneUptime's analytics schema runs as a sharded + replicated cluster on EVERY
7
+ * deployment — there is no separate single-node code path. A single node is just
8
+ * a "cluster of one": a 1-shard / 1-replica cluster backed by an (embedded)
9
+ * Keeper. For each model:
10
+ *
11
+ * - the model's `tableName` is a `Distributed` table the app reads from and
12
+ * writes to (scatter-gather on read, shard-routing on write);
13
+ * - the data lives in a local `<tableName>Local` table whose engine is the
14
+ * `Replicated*` variant of the model engine, so each shard's replicas hold a
15
+ * consistent copy of that shard's data (coordinated through Keeper).
16
+ *
17
+ * Because there is no dual mode, these helpers are unconditional: the storage
18
+ * table is always `<tableName>Local`, the engine is always `Replicated*`, and
19
+ * every object-lifecycle DDL statement carries `ON CLUSTER`.
20
+ *
21
+ * The cluster NAME is read live from process.env (CLICKHOUSE_CLUSTER_NAME,
22
+ * default "oneuptime") so it can be pointed at a differently-named external
23
+ * cluster, and so unit tests can vary it. It must match the cluster defined in
24
+ * the ClickHouse config / ClickHouseInstallation.
25
+ */
26
+
27
+ export const DEFAULT_CLICKHOUSE_CLUSTER_NAME: string = "oneuptime";
28
+ export const DEFAULT_CLICKHOUSE_SHARDING_KEY: string = "cityHash64(projectId)";
29
+ export const DEFAULT_CLICKHOUSE_DATABASE: string = "oneuptime";
30
+
31
+ // Suffix appended to a model's tableName to name its local (data) table.
32
+ export const LOCAL_TABLE_SUFFIX: string = "Local";
33
+
34
+ export function getClickhouseClusterName(): string {
35
+ const name: string = (process.env["CLICKHOUSE_CLUSTER_NAME"] || "").trim();
36
+ return name.length > 0 ? name : DEFAULT_CLICKHOUSE_CLUSTER_NAME;
37
+ }
38
+
39
+ /*
40
+ * Global sharding-key OVERRIDE (CLICKHOUSE_SHARDING_KEY). Empty by default,
41
+ * which means each model's own `shardingKey` is used (see getDistributedEngine).
42
+ * Set it to force one expression across all tables.
43
+ */
44
+ export function getClickhouseShardingKeyOverride(): string {
45
+ return (process.env["CLICKHOUSE_SHARDING_KEY"] || "").trim();
46
+ }
47
+
48
+ export function getClickhouseDatabaseName(): string {
49
+ return process.env["CLICKHOUSE_DATABASE"] || DEFAULT_CLICKHOUSE_DATABASE;
50
+ }
51
+
52
+ /*
53
+ * The `ON CLUSTER '<name>'` clause (with a leading space), emitted on every
54
+ * object-lifecycle DDL statement (CREATE TABLE / CREATE MATERIALIZED VIEW /
55
+ * DROP / RENAME) so the object is created or dropped on every node of the
56
+ * cluster. Per-table ALTERs and data mutations on ReplicatedMergeTree propagate
57
+ * through Keeper automatically; emitting ON CLUSTER on them too is harmless and
58
+ * keeps the reconcilers deterministic across replicas.
59
+ */
60
+ export function onClusterClause(): string {
61
+ return ` ON CLUSTER '${getClickhouseClusterName()}'`;
62
+ }
63
+
64
+ /*
65
+ * The physical table that stores a model's rows: `<tableName>Local` (a
66
+ * Replicated* table). All schema DDL (columns / indexes / projections) and data
67
+ * mutations (ALTER ... DELETE/UPDATE) target this name; the app-facing
68
+ * `tableName` is the Distributed wrapper.
69
+ */
70
+ export function getStorageTableName(tableName: string): string {
71
+ return `${tableName}${LOCAL_TABLE_SUFFIX}`;
72
+ }
73
+
74
+ /*
75
+ * Map a logical model engine to the `Replicated*` engine string for the local
76
+ * storage table, written WITHOUT explicit Keeper-path arguments so it relies on
77
+ * the server's `default_replica_path` / `default_replica_name` macros (the
78
+ * Altinity operator and the bundled embedded-Keeper config both provision these
79
+ * as `/clickhouse/tables/{uuid}/{shard}` and `{replica}`).
80
+ */
81
+ export function getStorageEngine(engine: AnalyticsTableEngine): string {
82
+ switch (engine) {
83
+ case AnalyticsTableEngine.AggregatingMergeTree:
84
+ return "ReplicatedAggregatingMergeTree";
85
+ case AnalyticsTableEngine.MergeTree:
86
+ default:
87
+ return "ReplicatedMergeTree";
88
+ }
89
+ }
90
+
91
+ /*
92
+ * The `Distributed(...)` engine string for the app-facing table wrapping a local
93
+ * storage table. The sharding key resolves as: global override
94
+ * (CLICKHOUSE_SHARDING_KEY) > the model's own shardingKey > cityHash64(projectId).
95
+ * internal_replication is configured on the cluster definition (true) so the
96
+ * Distributed table writes each row to one replica per shard and lets
97
+ * ReplicatedMergeTree fan it out.
98
+ */
99
+ export function getDistributedEngine(
100
+ localTableName: string,
101
+ modelShardingKey?: string | undefined,
102
+ ): string {
103
+ const cluster: string = getClickhouseClusterName();
104
+ const database: string = getClickhouseDatabaseName();
105
+ const shardingKey: string =
106
+ getClickhouseShardingKeyOverride() ||
107
+ (modelShardingKey && modelShardingKey.trim().length > 0
108
+ ? modelShardingKey.trim()
109
+ : DEFAULT_CLICKHOUSE_SHARDING_KEY);
110
+ return `Distributed('${cluster}', ${database}, ${localTableName}, ${shardingKey})`;
111
+ }
112
+
113
+ /*
114
+ * ReplicatedMergeTree deduplicates inserts via `replicated_deduplication_window`
115
+ * (coordinated through Keeper), whereas plain MergeTree uses
116
+ * `non_replicated_deduplication_window`. OneUptime's models declare the latter
117
+ * in their tableSettings; rewrite it for the replicated local tables so insert
118
+ * idempotency (retried telemetry batches keyed by a dedup token) is preserved.
119
+ */
120
+ export function adaptTableSettingsForStorage(
121
+ tableSettings: string | undefined,
122
+ ): string | undefined {
123
+ if (!tableSettings) {
124
+ return tableSettings;
125
+ }
126
+ return tableSettings.replace(
127
+ /non_replicated_deduplication_window/g,
128
+ "replicated_deduplication_window",
129
+ );
130
+ }
131
+
132
+ /*
133
+ * Rewrite a model's canonical `CREATE MATERIALIZED VIEW … TO <target> AS SELECT
134
+ * … FROM <source> …` statement for the cluster:
135
+ *
136
+ * 1. inject `ON CLUSTER '<name>'` after the view name, so the trigger exists
137
+ * on every node;
138
+ * 2. point `TO <target>` at the LOCAL target table (`<target>Local`), so each
139
+ * shard aggregates into its own replicated table;
140
+ * 3. point `FROM <source>` at the LOCAL source table (`<source>Local`), so the
141
+ * MV fires per-shard on local inserts rather than on the Distributed table.
142
+ *
143
+ * The Distributed wrapper over the target (created by the agg model's own
144
+ * CREATE) then scatter-gathers reads across shards.
145
+ *
146
+ * The replacements are deliberately precise: only the FIRST `TO`/`FROM` clause
147
+ * (the view's target/source) is rewritten, matched on the uppercase keyword our
148
+ * canonical definitions use — so `toStartOfMinute(...)` and aggregate columns
149
+ * are never touched. OneUptime MV definitions are single-source with no JOINs or
150
+ * subqueries, which this relies on.
151
+ */
152
+ export function applyClusterToMaterializedViewQuery(query: string): string {
153
+ const cluster: string = getClickhouseClusterName();
154
+
155
+ let result: string = query.replace(
156
+ /^(\s*CREATE\s+MATERIALIZED\s+VIEW\s+(?:IF\s+NOT\s+EXISTS\s+)?[A-Za-z_][A-Za-z0-9_]*)/,
157
+ (match: string): string => {
158
+ return `${match} ON CLUSTER '${cluster}'`;
159
+ },
160
+ );
161
+
162
+ result = result.replace(
163
+ /(\bTO\s+)([A-Za-z_][A-Za-z0-9_]*)/,
164
+ (_match: string, lead: string, table: string): string => {
165
+ return `${lead}${getStorageTableName(table)}`;
166
+ },
167
+ );
168
+
169
+ result = result.replace(
170
+ /(\bFROM\s+)([A-Za-z_][A-Za-z0-9_]*)/,
171
+ (_match: string, lead: string, table: string): string => {
172
+ return `${lead}${getStorageTableName(table)}`;
173
+ },
174
+ );
175
+
176
+ return result;
177
+ }