gitlab_quality-test_tooling 3.13.0 → 3.15.0
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.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/exe/test-coverage +31 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/client.rb +29 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/per_test_coverage_table.rb +169 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/table.rb +7 -12
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/test_health_risk_aggregation.sql +123 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/test_health_risk_aggregator.rb +114 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/per_test_coverage_data.rb +174 -0
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/client.rb +50 -0
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/config.rb +6 -40
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/config_helper.rb +25 -62
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/formatter.rb +24 -22
- data/lib/gitlab_quality/test_tooling/version.rb +1 -1
- metadata +8 -3
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/utils.rb +0 -87
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c8b4293811154f61f07fd8e35523a2406ba9832c6fe3e5eb3cf12164688df97a
|
|
4
|
+
data.tar.gz: c31082d6308fabe29ec51b4be09ebfd5d4c1d84e61ae8b3ce3741ffadcace9a6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1e522f9897eeddf1857caa10470ab1e11ae1ad7bad5062af80dbabdfb80e0fd11170f2011a2c28e9fcf5bb59394ca5124d9a88bedca637fbaec2e2fbf462503c
|
|
7
|
+
data.tar.gz: 1bb96de8d84feb07694145507df76707d88906b0671887b8104b79c3105859dda2c1d9ca6b68e0344478b955cdf91ec5f459ad6f1e48ca3612653b233c214531
|
data/Gemfile.lock
CHANGED
data/exe/test-coverage
CHANGED
|
@@ -10,13 +10,16 @@ require_relative "../lib/gitlab_quality/test_tooling"
|
|
|
10
10
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/category_owners'
|
|
11
11
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/category_owners_table'
|
|
12
12
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/coverage_metrics_table'
|
|
13
|
+
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/per_test_coverage_table'
|
|
13
14
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/test_file_mappings_table'
|
|
15
|
+
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/test_health_risk_aggregator'
|
|
14
16
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/coverage_data'
|
|
15
17
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/lcov_file'
|
|
16
18
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/artifacts'
|
|
17
19
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_report'
|
|
18
20
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_map'
|
|
19
21
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_file_mapping_data'
|
|
22
|
+
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/per_test_coverage_data'
|
|
20
23
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/source_file_classifier'
|
|
21
24
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/responsibility_classifier'
|
|
22
25
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/responsibility_patterns_config'
|
|
@@ -63,6 +66,13 @@ options = OptionParser.new do |opts|
|
|
|
63
66
|
params[:responsibility_patterns] = path
|
|
64
67
|
end
|
|
65
68
|
|
|
69
|
+
opts.on('--per-test-coverage GLOB',
|
|
70
|
+
'Optional. Glob pattern for per-test coverage JSON files. ' \
|
|
71
|
+
'When provided, populates code_coverage.test_coverage_per_file and runs the ' \
|
|
72
|
+
'daily test_health_risk aggregation. (e.g., "tmp/per-test-coverage-*.json")') do |pattern|
|
|
73
|
+
params[:per_test_coverage] = pattern
|
|
74
|
+
end
|
|
75
|
+
|
|
66
76
|
opts.separator ""
|
|
67
77
|
opts.separator "Environment variables:"
|
|
68
78
|
opts.separator " GLCI_CLICKHOUSE_METRICS_PASSWORD ClickHouse password (required, not passed via CLI for security)"
|
|
@@ -192,6 +202,27 @@ if params.any? && (required_params - params.keys).none?
|
|
|
192
202
|
)
|
|
193
203
|
test_file_mappings_table = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::TestFileMappingsTable.new(**shared_clickhouse_data)
|
|
194
204
|
test_file_mappings_table.push(test_file_mapping_data.as_db_table)
|
|
205
|
+
|
|
206
|
+
# Per-test coverage export (optional). Only runs when --per-test-coverage
|
|
207
|
+
# was provided AND at least one matching artifact exists.
|
|
208
|
+
if params[:per_test_coverage]
|
|
209
|
+
per_test_files = Dir.glob(params[:per_test_coverage])
|
|
210
|
+
if per_test_files.any?
|
|
211
|
+
per_test_data = GitlabQuality::TestTooling::CodeCoverage::PerTestCoverageData.new(
|
|
212
|
+
per_test_files,
|
|
213
|
+
tests_to_categories: tests_to_categories,
|
|
214
|
+
feature_categories_to_teams: category_owners.feature_categories_to_teams,
|
|
215
|
+
captured_sha: ENV.fetch('CI_COMMIT_SHA', '')
|
|
216
|
+
)
|
|
217
|
+
per_test_coverage_table = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::PerTestCoverageTable.new(**clickhouse_data)
|
|
218
|
+
per_test_coverage_table.push(per_test_data.as_db_table)
|
|
219
|
+
|
|
220
|
+
aggregator = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::TestHealthRiskAggregator.new(**clickhouse_data)
|
|
221
|
+
aggregator.run
|
|
222
|
+
else
|
|
223
|
+
puts "No per-test coverage artifacts matched #{params[:per_test_coverage]}; skipping per-test export and aggregation."
|
|
224
|
+
end
|
|
225
|
+
end
|
|
195
226
|
else
|
|
196
227
|
puts "Missing argument(s). Required arguments are: #{required_params}\nPassed arguments are: #{params}\n"
|
|
197
228
|
puts options
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GitlabQuality
|
|
4
|
+
module TestTooling
|
|
5
|
+
module CodeCoverage
|
|
6
|
+
module ClickHouse
|
|
7
|
+
# Memoized ClickHouse client accessor shared by `Table` and
|
|
8
|
+
# `TestHealthRiskAggregator`. Both classes need the same client
|
|
9
|
+
# construction from `@url` / `@database` / `@username` / `@password` /
|
|
10
|
+
# `@logger` instance variables; this module factors out the duplicated
|
|
11
|
+
# accessor without forcing one class to inherit from the other.
|
|
12
|
+
module Client
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
# @return [GitlabQuality::TestTooling::ClickHouse::Client]
|
|
16
|
+
def client
|
|
17
|
+
@client ||= GitlabQuality::TestTooling::ClickHouse::Client.new(
|
|
18
|
+
url: url,
|
|
19
|
+
database: database,
|
|
20
|
+
username: username,
|
|
21
|
+
password: password,
|
|
22
|
+
logger: logger
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'table'
|
|
4
|
+
|
|
5
|
+
module GitlabQuality
|
|
6
|
+
module TestTooling
|
|
7
|
+
module CodeCoverage
|
|
8
|
+
module ClickHouse
|
|
9
|
+
# Inserts per-test, per-source-file line-set coverage rows into
|
|
10
|
+
# `code_coverage.test_coverage_per_file`. The `covered_lines` column is
|
|
11
|
+
# `AggregateFunction(groupBitmap, UInt32)` so JSONEachRow can't carry it;
|
|
12
|
+
# rows go in via raw `INSERT ... VALUES` statements wrapping
|
|
13
|
+
# `bitmapBuild(CAST([line, ...] AS Array(UInt32)))` per row.
|
|
14
|
+
#
|
|
15
|
+
# Dedup across runs is handled by the table's
|
|
16
|
+
# `SharedReplacingMergeTree(version)` engine on
|
|
17
|
+
# `(ci_project_path, test_file, source_file)` ORDER BY. Within a single
|
|
18
|
+
# run, callers must pre-aggregate at the (test_file, source_file) grain
|
|
19
|
+
# before pushing: multiple examples within the same test_file should be
|
|
20
|
+
# unioned into one row by the loader, not handed in as duplicates.
|
|
21
|
+
class PerTestCoverageTable < GitlabQuality::TestTooling::CodeCoverage::ClickHouse::Table
|
|
22
|
+
TABLE_NAME = "test_coverage_per_file"
|
|
23
|
+
BATCH_SIZE = 500
|
|
24
|
+
# Intentionally generous ceiling on line numbers. Real source files
|
|
25
|
+
# are thousands of lines; generated artifacts (large GraphQL schemas,
|
|
26
|
+
# bundled JS, JSON manifests) can run past 100k. The cap is set to
|
|
27
|
+
# flag clearly bogus values (negative, garbage casts, anything past
|
|
28
|
+
# ~1M) without rejecting realistic generated files. ClickHouse's
|
|
29
|
+
# UInt32 ceiling is ~4.3B, so we still have orders of magnitude of
|
|
30
|
+
# headroom above this. Tighten only with evidence.
|
|
31
|
+
MAX_LINE_NUMBER = 1_000_000
|
|
32
|
+
|
|
33
|
+
# @param data [Array<Hash>] one entry per (test_file, source_file). Each entry needs:
|
|
34
|
+
# :test_file [String]
|
|
35
|
+
# :source_file [String]
|
|
36
|
+
# :covered_lines [Array<Integer>] line numbers covered by this test on this file
|
|
37
|
+
# :total_lines [Integer] executable lines in the source file
|
|
38
|
+
# :feature_category, :group, :stage, :section [String, optional]
|
|
39
|
+
# @return [void]
|
|
40
|
+
def push(data) # rubocop:disable Metrics/AbcSize
|
|
41
|
+
return logger.warn("#{LOG_PREFIX} No data found, skipping ClickHouse export!") if data.empty?
|
|
42
|
+
|
|
43
|
+
logger.debug("#{LOG_PREFIX} Starting per-test coverage export to ClickHouse")
|
|
44
|
+
sanitized_data = sanitize(data)
|
|
45
|
+
|
|
46
|
+
return logger.warn("#{LOG_PREFIX} No valid data found after sanitization, skipping ClickHouse export!") if sanitized_data.empty?
|
|
47
|
+
|
|
48
|
+
total_batches = (sanitized_data.size.to_f / BATCH_SIZE).ceil
|
|
49
|
+
sanitized_data.each_slice(BATCH_SIZE).with_index do |batch, index|
|
|
50
|
+
logger.debug("#{LOG_PREFIX} Pushing batch #{index + 1} of #{total_batches} (#{batch.size} rows)")
|
|
51
|
+
client.query(build_insert_sql(batch), format: "TabSeparated")
|
|
52
|
+
end
|
|
53
|
+
logger.info("#{LOG_PREFIX} Successfully pushed #{sanitized_data.size} records to #{full_table_name}")
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
logger.error("#{LOG_PREFIX} Error occurred while pushing data to #{full_table_name}: #{e.message}")
|
|
56
|
+
raise
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def valid_record?(record)
|
|
62
|
+
valid_test_file?(record) && valid_source_file?(record) && valid_covered_lines?(record)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def valid_test_file?(record)
|
|
66
|
+
return true unless record[:test_file].blank?
|
|
67
|
+
|
|
68
|
+
logger.warn("#{LOG_PREFIX} Skipping record with nil/empty test_file: #{record}")
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def valid_source_file?(record)
|
|
73
|
+
return true unless record[:source_file].blank?
|
|
74
|
+
|
|
75
|
+
logger.warn("#{LOG_PREFIX} Skipping record with nil/empty source_file: #{record}")
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def valid_covered_lines?(record)
|
|
80
|
+
covered = record[:covered_lines]
|
|
81
|
+
return true if covered.is_a?(Array) && !covered.empty?
|
|
82
|
+
|
|
83
|
+
logger.warn("#{LOG_PREFIX} Skipping record with empty/invalid covered_lines: #{record[:test_file]} → #{record[:source_file]}")
|
|
84
|
+
false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def sanitized_data_record(record) # rubocop:disable Metrics/AbcSize
|
|
88
|
+
sanitized_lines = sanitize_lines(record[:covered_lines])
|
|
89
|
+
|
|
90
|
+
# `valid_covered_lines?` only checks the raw input is a non-empty
|
|
91
|
+
# Array. Post-sanitisation, every entry could still be filtered
|
|
92
|
+
# out (negatives, zeros, values past MAX_LINE_NUMBER). An empty
|
|
93
|
+
# `bitmapBuild([])` row carries no useful signal for the
|
|
94
|
+
# aggregation and just wastes a tuple, so drop the record here.
|
|
95
|
+
if sanitized_lines.empty?
|
|
96
|
+
logger.warn(
|
|
97
|
+
"#{LOG_PREFIX} Skipping record whose covered_lines emptied after sanitisation: " \
|
|
98
|
+
"#{record[:test_file]} → #{record[:source_file]}"
|
|
99
|
+
)
|
|
100
|
+
return nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
{
|
|
104
|
+
timestamp: time,
|
|
105
|
+
ci_project_path: ENV.fetch('CI_PROJECT_PATH', nil),
|
|
106
|
+
test_file: record[:test_file],
|
|
107
|
+
source_file: record[:source_file],
|
|
108
|
+
covered_lines: sanitized_lines,
|
|
109
|
+
total_lines: record[:total_lines].to_i,
|
|
110
|
+
feature_category: record[:feature_category] || '',
|
|
111
|
+
group: record[:group] || '',
|
|
112
|
+
stage: record[:stage] || '',
|
|
113
|
+
section: record[:section] || '',
|
|
114
|
+
captured_sha: record[:captured_sha].to_s
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def build_insert_sql(batch)
|
|
119
|
+
rows_sql = batch.map { |record| build_row_sql(record) }.join(",\n")
|
|
120
|
+
<<~SQL
|
|
121
|
+
INSERT INTO #{full_table_name}
|
|
122
|
+
(timestamp, ci_project_path, test_file, source_file, covered_lines, total_lines, feature_category, `group`, stage, section, captured_sha)
|
|
123
|
+
VALUES
|
|
124
|
+
#{rows_sql}
|
|
125
|
+
SQL
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Precondition: `record[:covered_lines]` is the sanitised integer
|
|
129
|
+
# array produced upstream by `sanitize_lines` (via
|
|
130
|
+
# `sanitized_data_record`). Values are positive integers within
|
|
131
|
+
# MAX_LINE_NUMBER; no defensive validation is repeated here because
|
|
132
|
+
# this method is on the hot path (every row in every batch).
|
|
133
|
+
def build_row_sql(record) # rubocop:disable Metrics/AbcSize
|
|
134
|
+
line_array = "[#{record[:covered_lines].join(',')}]"
|
|
135
|
+
timestamp_str = record[:timestamp].iso8601(3)
|
|
136
|
+
"(" \
|
|
137
|
+
"'#{timestamp_str}', " \
|
|
138
|
+
"'#{escape(record[:ci_project_path])}', " \
|
|
139
|
+
"'#{escape(record[:test_file])}', " \
|
|
140
|
+
"'#{escape(record[:source_file])}', " \
|
|
141
|
+
"bitmapBuild(CAST(#{line_array} AS Array(UInt32))), " \
|
|
142
|
+
"#{record[:total_lines]}, " \
|
|
143
|
+
"'#{escape(record[:feature_category])}', " \
|
|
144
|
+
"'#{escape(record[:group])}', " \
|
|
145
|
+
"'#{escape(record[:stage])}', " \
|
|
146
|
+
"'#{escape(record[:section])}', " \
|
|
147
|
+
"'#{escape(record[:captured_sha])}'" \
|
|
148
|
+
")"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Filter line numbers down to positive integers within MAX_LINE_NUMBER.
|
|
152
|
+
# Drops anything that isn't a valid line number; doesn't raise so a
|
|
153
|
+
# single bad row doesn't fail the whole batch.
|
|
154
|
+
def sanitize_lines(lines)
|
|
155
|
+
Array(lines).filter_map do |line|
|
|
156
|
+
n = line.to_i
|
|
157
|
+
n if n.positive? && n <= MAX_LINE_NUMBER
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# ClickHouse string escape: backslash and single quote.
|
|
162
|
+
def escape(str)
|
|
163
|
+
str.to_s.gsub(/\\/, '\\\\\\\\').gsub("'", "''") # rubocop:disable Style/RedundantRegexpArgument
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -2,12 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
require 'time'
|
|
4
4
|
|
|
5
|
+
require_relative 'client'
|
|
6
|
+
|
|
5
7
|
module GitlabQuality
|
|
6
8
|
module TestTooling
|
|
7
9
|
module CodeCoverage
|
|
8
10
|
module ClickHouse
|
|
11
|
+
# Shared log prefix for all classes in this namespace. Hoisted up from
|
|
12
|
+
# individual classes so the prefix can change in one place.
|
|
13
|
+
LOG_PREFIX = "[CodeCoverage]" unless defined?(LOG_PREFIX)
|
|
14
|
+
|
|
9
15
|
class Table
|
|
10
|
-
|
|
16
|
+
include Client
|
|
11
17
|
|
|
12
18
|
def initialize(url:, database:, username: nil, password: nil, logger: nil)
|
|
13
19
|
@url = url
|
|
@@ -74,17 +80,6 @@ module GitlabQuality
|
|
|
74
80
|
logger.warn("#{LOG_PREFIX} Invalid CI_PIPELINE_CREATED_AT format: #{ci_created_at}, using current time")
|
|
75
81
|
Time.now.utc
|
|
76
82
|
end
|
|
77
|
-
|
|
78
|
-
# @return [GitlabQuality::TestTooling::ClickHouse::Client]
|
|
79
|
-
def client
|
|
80
|
-
@client ||= GitlabQuality::TestTooling::ClickHouse::Client.new(
|
|
81
|
-
url: url,
|
|
82
|
-
database: database,
|
|
83
|
-
username: username,
|
|
84
|
-
password: password,
|
|
85
|
-
logger: logger
|
|
86
|
-
)
|
|
87
|
-
end
|
|
88
83
|
end
|
|
89
84
|
end
|
|
90
85
|
end
|
data/lib/gitlab_quality/test_tooling/code_coverage/click_house/test_health_risk_aggregation.sql
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
-- Daily aggregation of per-test coverage data into per-group risk summary.
|
|
2
|
+
--
|
|
3
|
+
-- Reads the bitmap line sets from `code_coverage.test_coverage_per_file`,
|
|
4
|
+
-- joins per-test quarantine and flaky status from `test_metrics`, then
|
|
5
|
+
-- computes for each source file:
|
|
6
|
+
-- quarantined_union = union of line sets covered by *quarantined* tests
|
|
7
|
+
-- flaky_union = union of line sets covered by *flaky* tests
|
|
8
|
+
-- quarantined_or_flaky_union = union of line sets covered by *quarantined OR flaky* tests
|
|
9
|
+
-- healthy_union = union of line sets covered by tests that are neither
|
|
10
|
+
--
|
|
11
|
+
-- The "at risk" line counts are:
|
|
12
|
+
-- at_risk_lines_quarantine = lines uniquely covered by quarantined tests
|
|
13
|
+
-- (not covered by any healthy test)
|
|
14
|
+
-- at_risk_lines_flaky = lines uniquely covered by flaky tests
|
|
15
|
+
-- at_risk_lines_combined = lines uniquely covered by quarantined OR flaky
|
|
16
|
+
--
|
|
17
|
+
-- Healthy union excludes flaky tests too, by design: a flaky test isn't a
|
|
18
|
+
-- reliable coverage safety net, so its lines shouldn't count toward the
|
|
19
|
+
-- baseline against which quarantine-only risk is measured.
|
|
20
|
+
--
|
|
21
|
+
-- Result: one row per (snapshot_date, group, stage, section) tuple inserted
|
|
22
|
+
-- into `code_coverage.test_health_risk_per_group`.
|
|
23
|
+
--
|
|
24
|
+
-- Parameter substitution: this template uses `{name}` braces (not %{name} or
|
|
25
|
+
-- :name). They're replaced by literal text via `gsub` in
|
|
26
|
+
-- TestHealthRiskAggregator#build_sql, after each parameter passes a regex
|
|
27
|
+
-- check. A typoed placeholder name will silently fall through gsub as a
|
|
28
|
+
-- literal — match the names exactly.
|
|
29
|
+
--
|
|
30
|
+
-- {snapshot_date} : the date stamp for this run, e.g. '2026-05-07'
|
|
31
|
+
-- {coverage_window} : interval to look back for fresh per-test rows,
|
|
32
|
+
-- default '2 DAY' (see Resilience note below)
|
|
33
|
+
-- {risk_window} : interval to consider quarantine/flaky status from
|
|
34
|
+
-- the test_metrics summaries, default '30 DAY'
|
|
35
|
+
|
|
36
|
+
-- Idempotency: this INSERT is unconditional. The target table must use
|
|
37
|
+
-- SharedReplacingMergeTree(version) (or ReplacingMergeTree on non-Cloud
|
|
38
|
+
-- ClickHouse; Cloud silently rewrites to Shared...) keyed by
|
|
39
|
+
-- (snapshot_date, group, stage, section), with
|
|
40
|
+
-- `version UInt64 MATERIALIZED toUnixTimestamp64Milli(now64(3))`, so re-running
|
|
41
|
+
-- for the same snapshot_date produces a higher version that replaces the
|
|
42
|
+
-- previous row on merge. Without that engine, re-runs would duplicate rows.
|
|
43
|
+
--
|
|
44
|
+
-- Resilience: `{coverage_window}` defaults to 2 DAY (see
|
|
45
|
+
-- TestHealthRiskAggregator::DEFAULT_COVERAGE_WINDOW). One missed nightly
|
|
46
|
+
-- run is recovered on the next night because the aggregation still sees
|
|
47
|
+
-- the previous day's per-test rows. Cross-run race: if two jobs land
|
|
48
|
+
-- inserts overlapping with the aggregation, the later aggregation wins
|
|
49
|
+
-- because of the version-based replacement.
|
|
50
|
+
INSERT INTO code_coverage.test_health_risk_per_group
|
|
51
|
+
WITH
|
|
52
|
+
quarantine_status AS (
|
|
53
|
+
-- `date` is the daily aggregation timestamp on the test_metrics summary
|
|
54
|
+
-- view (when the per-test counts were rolled up), not the date a test
|
|
55
|
+
-- entered quarantine. We treat any test marked quarantined within the
|
|
56
|
+
-- risk window as currently quarantined.
|
|
57
|
+
SELECT
|
|
58
|
+
test_file,
|
|
59
|
+
uniqIfMerge(quarantined_cases) > 0 AS is_quarantined
|
|
60
|
+
FROM test_metrics.test_file_quarantine_summary
|
|
61
|
+
WHERE date >= now() - INTERVAL {risk_window}
|
|
62
|
+
GROUP BY test_file
|
|
63
|
+
HAVING is_quarantined
|
|
64
|
+
),
|
|
65
|
+
flaky_status AS (
|
|
66
|
+
SELECT
|
|
67
|
+
test_file,
|
|
68
|
+
uniqIfMerge(flaky_cases) > 0 AS is_flaky
|
|
69
|
+
FROM test_metrics.test_file_flaky_summary
|
|
70
|
+
WHERE date >= now() - INTERVAL {risk_window}
|
|
71
|
+
GROUP BY test_file
|
|
72
|
+
HAVING is_flaky
|
|
73
|
+
),
|
|
74
|
+
per_test_status AS (
|
|
75
|
+
SELECT
|
|
76
|
+
tc.source_file,
|
|
77
|
+
tc.`group`,
|
|
78
|
+
tc.stage,
|
|
79
|
+
tc.section,
|
|
80
|
+
tc.total_lines,
|
|
81
|
+
tc.covered_lines,
|
|
82
|
+
coalesce(qs.is_quarantined, FALSE) AS is_quarantined,
|
|
83
|
+
coalesce(fs.is_flaky, FALSE) AS is_flaky
|
|
84
|
+
FROM code_coverage.test_coverage_per_file tc FINAL
|
|
85
|
+
LEFT JOIN quarantine_status qs ON qs.test_file = tc.test_file
|
|
86
|
+
LEFT JOIN flaky_status fs ON fs.test_file = tc.test_file
|
|
87
|
+
WHERE tc.timestamp >= now() - INTERVAL {coverage_window}
|
|
88
|
+
),
|
|
89
|
+
per_file AS (
|
|
90
|
+
SELECT
|
|
91
|
+
source_file,
|
|
92
|
+
`group`,
|
|
93
|
+
stage,
|
|
94
|
+
section,
|
|
95
|
+
max(total_lines) AS total_lines,
|
|
96
|
+
groupBitmapMergeStateIf(covered_lines, is_quarantined) AS quarantined_union,
|
|
97
|
+
groupBitmapMergeStateIf(covered_lines, is_flaky) AS flaky_union,
|
|
98
|
+
groupBitmapMergeStateIf(covered_lines, is_quarantined OR is_flaky) AS quarantined_or_flaky_union,
|
|
99
|
+
groupBitmapMergeStateIf(covered_lines, NOT is_quarantined AND NOT is_flaky) AS healthy_union
|
|
100
|
+
FROM per_test_status
|
|
101
|
+
GROUP BY source_file, `group`, stage, section
|
|
102
|
+
)
|
|
103
|
+
SELECT
|
|
104
|
+
toDate('{snapshot_date}') AS snapshot_date,
|
|
105
|
+
`group`,
|
|
106
|
+
stage,
|
|
107
|
+
section,
|
|
108
|
+
count(*) AS source_file_count,
|
|
109
|
+
-- at_risk_lines_combined ≤ at_risk_lines_quarantine + at_risk_lines_flaky.
|
|
110
|
+
-- A line covered by both a quarantined and a flaky test is single-counted
|
|
111
|
+
-- in quarantined_or_flaky_union, so the combined column is the *unique*
|
|
112
|
+
-- exclusive-line loss across both risk sources, not their sum.
|
|
113
|
+
sum(bitmapCardinality(bitmapAndnot(quarantined_union, healthy_union))) AS at_risk_lines_quarantine,
|
|
114
|
+
sum(bitmapCardinality(bitmapAndnot(flaky_union, healthy_union))) AS at_risk_lines_flaky,
|
|
115
|
+
sum(bitmapCardinality(bitmapAndnot(quarantined_or_flaky_union, healthy_union))) AS at_risk_lines_combined,
|
|
116
|
+
-- team_executable_lines is the per-team executable-line denominator across
|
|
117
|
+
-- every (source_file, team) row owned by the team. A source file owned by
|
|
118
|
+
-- tests in two different (group, stage, section) tuples contributes to each
|
|
119
|
+
-- team's total separately, so summing this column across teams is NOT the
|
|
120
|
+
-- codebase-wide line count.
|
|
121
|
+
sum(total_lines) AS team_executable_lines
|
|
122
|
+
FROM per_file
|
|
123
|
+
GROUP BY `group`, stage, section;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'client'
|
|
4
|
+
require_relative 'table'
|
|
5
|
+
|
|
6
|
+
module GitlabQuality
|
|
7
|
+
module TestTooling
|
|
8
|
+
module CodeCoverage
|
|
9
|
+
module ClickHouse
|
|
10
|
+
# Runs the daily aggregation that turns `code_coverage.test_coverage_per_file`
|
|
11
|
+
# rows into a small `code_coverage.test_health_risk_per_group` summary
|
|
12
|
+
# the dashboard reads.
|
|
13
|
+
#
|
|
14
|
+
# Hybrid model: this Ruby class is the orchestrator (schedule, error
|
|
15
|
+
# handling, parameter substitution); ClickHouse runs the bitmap math
|
|
16
|
+
# via `INSERT ... SELECT` from the SQL file shipped alongside.
|
|
17
|
+
class TestHealthRiskAggregator
|
|
18
|
+
include Client
|
|
19
|
+
|
|
20
|
+
SQL_FILE = File.expand_path('test_health_risk_aggregation.sql', __dir__)
|
|
21
|
+
|
|
22
|
+
# 2 DAY rather than 1 DAY makes the aggregation self-healing across
|
|
23
|
+
# a single missed nightly run: if last night's export failed, this
|
|
24
|
+
# night's run still sees yesterday's per-test rows and produces a
|
|
25
|
+
# current snapshot. ReplacingMergeTree FINAL on the source table
|
|
26
|
+
# ensures we read only the latest version per (test_file, source_file).
|
|
27
|
+
# ClickHouse accepts both '2 DAY' and '2 DAYS'; we use the singular
|
|
28
|
+
# form for consistency with `30 DAY` below.
|
|
29
|
+
DEFAULT_COVERAGE_WINDOW = '2 DAY'
|
|
30
|
+
DEFAULT_RISK_WINDOW = '30 DAY'
|
|
31
|
+
|
|
32
|
+
# `snapshot_date` is YYYY-MM-DD; intervals are `<integer> <unit>`
|
|
33
|
+
# (singular or plural).
|
|
34
|
+
DATE_PATTERN = /\A\d{4}-\d{2}-\d{2}\z/
|
|
35
|
+
INTERVAL_PATTERN = /\A\d+\s+(SECOND|MINUTE|HOUR|DAY|WEEK|MONTH|QUARTER|YEAR)S?\z/i
|
|
36
|
+
|
|
37
|
+
def initialize(
|
|
38
|
+
url:, database:, username: nil, password: nil, logger: nil,
|
|
39
|
+
coverage_window: DEFAULT_COVERAGE_WINDOW, risk_window: DEFAULT_RISK_WINDOW)
|
|
40
|
+
@url = url
|
|
41
|
+
@database = database
|
|
42
|
+
@username = username
|
|
43
|
+
@password = password
|
|
44
|
+
@logger = logger || ::Logger.new($stdout, level: 1)
|
|
45
|
+
@coverage_window = coverage_window
|
|
46
|
+
@risk_window = risk_window
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param snapshot_date [Date, String] date stamp for this run; defaults to today.
|
|
50
|
+
# @return [void]
|
|
51
|
+
def run(snapshot_date: Date.today) # rubocop:disable Metrics/AbcSize
|
|
52
|
+
sql = build_sql(snapshot_date: snapshot_date)
|
|
53
|
+
logger.info(
|
|
54
|
+
"#{LOG_PREFIX} Running test_health_risk aggregation snapshot_date=#{snapshot_date} " \
|
|
55
|
+
"coverage_window=#{coverage_window} risk_window=#{risk_window}"
|
|
56
|
+
)
|
|
57
|
+
client.query(sql, format: "TabSeparated")
|
|
58
|
+
inserted = fetch_row_count(snapshot_date)
|
|
59
|
+
if inserted.is_a?(Integer) && inserted.zero?
|
|
60
|
+
logger.warn(
|
|
61
|
+
"#{LOG_PREFIX} Aggregation wrote 0 rows for snapshot_date=#{snapshot_date}. " \
|
|
62
|
+
"This is valid if no per-test data is in the coverage_window, but worth checking " \
|
|
63
|
+
"test_coverage_per_file directly if a recent export ran."
|
|
64
|
+
)
|
|
65
|
+
else
|
|
66
|
+
logger.info("#{LOG_PREFIX} Aggregation wrote #{inserted} rows for snapshot_date=#{snapshot_date}")
|
|
67
|
+
end
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
logger.error("#{LOG_PREFIX} Aggregation failed for #{snapshot_date}: #{e.message}")
|
|
70
|
+
raise
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
attr_reader :url, :database, :username, :password, :logger, :coverage_window, :risk_window
|
|
76
|
+
|
|
77
|
+
def build_sql(snapshot_date:)
|
|
78
|
+
template = File.read(SQL_FILE)
|
|
79
|
+
template
|
|
80
|
+
.gsub('{snapshot_date}', validate_date(snapshot_date))
|
|
81
|
+
.gsub('{coverage_window}', validate_interval(coverage_window))
|
|
82
|
+
.gsub('{risk_window}', validate_interval(risk_window))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# DateTime/Time `to_s` includes the time portion and is rejected.
|
|
86
|
+
def validate_date(value)
|
|
87
|
+
str = value.to_s
|
|
88
|
+
raise ArgumentError, "Invalid snapshot_date: #{value.inspect}" unless DATE_PATTERN.match?(str)
|
|
89
|
+
|
|
90
|
+
str
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def validate_interval(value)
|
|
94
|
+
raise ArgumentError, "Invalid interval expression: #{value.inspect}" unless INTERVAL_PATTERN.match?(value.to_s)
|
|
95
|
+
|
|
96
|
+
value
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Returns 'unknown' on any error so a transient count-query failure
|
|
100
|
+
# can't mask the success of the actual INSERT.
|
|
101
|
+
def fetch_row_count(snapshot_date)
|
|
102
|
+
count_sql = "SELECT count() FROM code_coverage.test_health_risk_per_group FINAL " \
|
|
103
|
+
"WHERE snapshot_date = toDate('#{validate_date(snapshot_date)}')"
|
|
104
|
+
result = client.query(count_sql, format: "JSONCompact")
|
|
105
|
+
result&.dig('data', 0, 0) || 'unknown'
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
logger.debug("#{LOG_PREFIX} Could not fetch post-aggregation row count: #{e.message}")
|
|
108
|
+
'unknown'
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module GitlabQuality
|
|
6
|
+
module TestTooling
|
|
7
|
+
module CodeCoverage
|
|
8
|
+
# Reads per-test coverage files and produces rows for
|
|
9
|
+
# `PerTestCoverageTable`.
|
|
10
|
+
#
|
|
11
|
+
# Two input formats are supported, dispatched by file extension:
|
|
12
|
+
#
|
|
13
|
+
# `.json`: one document with the example id as the outer key.
|
|
14
|
+
# {
|
|
15
|
+
# "spec/path/to/test_spec.rb[1:1]": {
|
|
16
|
+
# "app/path/to/source.rb": [null, 1, 0, 5, 1, ...]
|
|
17
|
+
# },
|
|
18
|
+
# ...
|
|
19
|
+
# }
|
|
20
|
+
#
|
|
21
|
+
# `.ndjson`: one JSON object per line, with `id` and `files` fields.
|
|
22
|
+
# {"id":"spec/path/to/test_spec.rb[1:1]","files":{"app/path/to/source.rb":[null,1,0,5,1]}}
|
|
23
|
+
# {"id":"spec/path/to/test_spec.rb[1:2]","files":{"app/path/to/source.rb":[null,0,1,0,1]}}
|
|
24
|
+
#
|
|
25
|
+
# The NDJSON form lets the producing formatter stream per-example data
|
|
26
|
+
# to disk without holding the full suite in memory. Both forms carry
|
|
27
|
+
# the same per-test data; the parser is symmetric.
|
|
28
|
+
#
|
|
29
|
+
# Inner key (in either form) is a source file path. Inner value is a
|
|
30
|
+
# 0-indexed array of per-line hit counts. `null` means non-executable;
|
|
31
|
+
# `0` means executable but not hit by this test; positive integer means
|
|
32
|
+
# executed. This is the standard Ruby `Coverage` module output shape,
|
|
33
|
+
# also produced by any per-test capture that emits one line-hit array
|
|
34
|
+
# per (test, file) pair.
|
|
35
|
+
#
|
|
36
|
+
# This class:
|
|
37
|
+
# - strips `[<example_uid>]` from the example id to get a per-test-file key
|
|
38
|
+
# - converts each line-hit array into a (covered_lines, total_lines) pair
|
|
39
|
+
# - pre-aggregates within (test_file, source_file): unions covered
|
|
40
|
+
# lines across all examples in the same test file, takes the max
|
|
41
|
+
# total_lines
|
|
42
|
+
# - drops rows with empty bitmaps (file imported but no line hit)
|
|
43
|
+
# - enriches with feature_category / group / stage / section when test
|
|
44
|
+
# metadata is provided
|
|
45
|
+
class PerTestCoverageData
|
|
46
|
+
# Raised when a coverage artifact can't be parsed. Wraps the underlying
|
|
47
|
+
# `JSON::ParserError` or `Errno::ENOENT` so callers outside the
|
|
48
|
+
# gitlab-org/gitlab CI context (where upstream `needs:` ordering
|
|
49
|
+
# guarantees well-formed artifacts) can rescue precisely without
|
|
50
|
+
# catching unrelated standard exceptions.
|
|
51
|
+
ParseError = Class.new(StandardError)
|
|
52
|
+
|
|
53
|
+
# @param coverage_files [Array<String>] paths to per-test coverage JSON artifacts
|
|
54
|
+
# @param tests_to_categories [Hash<String, Array<String>>] test_file => [feature_category]
|
|
55
|
+
# @param feature_categories_to_teams [Hash<String, Hash>] category => {group:, stage:, section:}
|
|
56
|
+
# @param captured_sha [String] the git SHA the coverage was captured against; attached to
|
|
57
|
+
# every emitted row so downstream delta-capture jobs can ask
|
|
58
|
+
# `SELECT max(captured_sha) FROM code_coverage.test_coverage_per_file` to find the
|
|
59
|
+
# previous successful capture point. Defaults to '' when unknown.
|
|
60
|
+
# @raise [ParseError] if a coverage file is missing or contains invalid JSON
|
|
61
|
+
def initialize(coverage_files, tests_to_categories: {}, feature_categories_to_teams: {}, captured_sha: '')
|
|
62
|
+
@coverage_files = Array(coverage_files)
|
|
63
|
+
@tests_to_categories = tests_to_categories
|
|
64
|
+
@feature_categories_to_teams = feature_categories_to_teams
|
|
65
|
+
@captured_sha = captured_sha.to_s
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Array<Hash<Symbol, Object>>] per-test-file, per-source-file rows for PerTestCoverageTable
|
|
69
|
+
def as_db_table # rubocop:disable Metrics/AbcSize
|
|
70
|
+
aggregated = {}
|
|
71
|
+
|
|
72
|
+
@coverage_files.each do |path|
|
|
73
|
+
each_example(path) do |example_id, files|
|
|
74
|
+
test_file = extract_test_file_path(example_id)
|
|
75
|
+
files.each do |source_file, line_hits|
|
|
76
|
+
covered, total = parse_line_hits(line_hits)
|
|
77
|
+
next if covered.empty?
|
|
78
|
+
|
|
79
|
+
key = [test_file, source_file]
|
|
80
|
+
if aggregated.key?(key)
|
|
81
|
+
aggregated[key][:covered_lines].merge(covered)
|
|
82
|
+
# max rather than picking either side: examples within the
|
|
83
|
+
# same test file may report arrays of different lengths if
|
|
84
|
+
# the source file was edited mid-run. Pragmatic, not exact.
|
|
85
|
+
aggregated[key][:total_lines] = [aggregated[key][:total_lines], total].max
|
|
86
|
+
else
|
|
87
|
+
# dup so the merge above can never alias a Set returned by
|
|
88
|
+
# parse_line_hits to a different key later in the loop.
|
|
89
|
+
aggregated[key] = { covered_lines: covered.dup, total_lines: total }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
aggregated.map do |(test_file, source_file), agg|
|
|
96
|
+
category = @tests_to_categories[test_file]&.first || ''
|
|
97
|
+
team = @feature_categories_to_teams[category] || {}
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
test_file: test_file,
|
|
101
|
+
source_file: source_file,
|
|
102
|
+
covered_lines: agg[:covered_lines].to_a.sort,
|
|
103
|
+
total_lines: agg[:total_lines],
|
|
104
|
+
feature_category: category,
|
|
105
|
+
group: team[:group] || '',
|
|
106
|
+
stage: team[:stage] || '',
|
|
107
|
+
section: team[:section] || '',
|
|
108
|
+
captured_sha: @captured_sha
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
# Yield (example_id, files) pairs from one input file. Dispatches
|
|
116
|
+
# on extension: `.ndjson` is parsed line-by-line so a multi-GB
|
|
117
|
+
# capture file does not need to fit in memory; everything else is
|
|
118
|
+
# parsed as a single JSON document with `{example_id => files}` at
|
|
119
|
+
# the top level. Both forms wrap parse failures in `ParseError` so
|
|
120
|
+
# callers can rescue without naming the underlying exception classes.
|
|
121
|
+
def each_example(path)
|
|
122
|
+
if path.end_with?('.ndjson')
|
|
123
|
+
File.foreach(path) do |line|
|
|
124
|
+
line = line.strip
|
|
125
|
+
next if line.empty?
|
|
126
|
+
|
|
127
|
+
entry = JSON.parse(line)
|
|
128
|
+
yield entry.fetch('id'), entry.fetch('files')
|
|
129
|
+
end
|
|
130
|
+
else
|
|
131
|
+
JSON.parse(File.read(path)).each do |example_id, files|
|
|
132
|
+
yield example_id, files
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
rescue JSON::ParserError, Errno::ENOENT => e
|
|
136
|
+
raise ParseError, "Failed to parse coverage artifact #{path}: #{e.message}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Normalise an example id (`<test_file>[<uid>]:<line>`) to the
|
|
140
|
+
# bare test_file path. Strips a leading `./`, the trailing
|
|
141
|
+
# `[<uid>]`, and any `:<line>` suffix so multiple examples within
|
|
142
|
+
# the same spec file collapse to the same test_file key. Path
|
|
143
|
+
# separator handling assumes Linux paths (everything in CI is
|
|
144
|
+
# Linux); a Windows-style `C:/foo/spec.rb` would split incorrectly
|
|
145
|
+
# on the first `:`.
|
|
146
|
+
def extract_test_file_path(example_id)
|
|
147
|
+
stripped = example_id.delete_prefix('./')
|
|
148
|
+
stripped = stripped.sub(/\[.+\]\z/, '')
|
|
149
|
+
stripped.split(':').first
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Walk the per-line hit array. Returns (Set<Integer> covered_lines,
|
|
153
|
+
# Integer total_executable_lines). Indexes are 0-based; line numbers
|
|
154
|
+
# are 1-based. `nil` entries are non-executable lines.
|
|
155
|
+
# A `nil` value at the file level (file loaded but never recorded
|
|
156
|
+
# under this example) yields an empty result rather than raising,
|
|
157
|
+
# so a single odd cell doesn't fail the whole export.
|
|
158
|
+
def parse_line_hits(line_hits)
|
|
159
|
+
return [Set.new, 0] unless line_hits.is_a?(Array)
|
|
160
|
+
|
|
161
|
+
covered = Set.new
|
|
162
|
+
total = 0
|
|
163
|
+
line_hits.each_with_index do |hits, index|
|
|
164
|
+
next if hits.nil?
|
|
165
|
+
|
|
166
|
+
total += 1
|
|
167
|
+
covered.add(index + 1) if hits.is_a?(Numeric) && hits.positive?
|
|
168
|
+
end
|
|
169
|
+
[covered, total]
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "httparty"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module GitlabQuality
|
|
7
|
+
module TestTooling
|
|
8
|
+
module TestMetricsExporter
|
|
9
|
+
class Client
|
|
10
|
+
ResponseError = Class.new(StandardError)
|
|
11
|
+
|
|
12
|
+
TESTS_PATH = "/api/v1/tests"
|
|
13
|
+
# Observer enforces a per-request cap; batch above this size to avoid silent failures.
|
|
14
|
+
MAX_BATCH_SIZE = 10_000
|
|
15
|
+
|
|
16
|
+
def initialize(url:, token:)
|
|
17
|
+
@url = url
|
|
18
|
+
@token = token
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# POST array of test metric records to the observer service.
|
|
22
|
+
# Wraps each batch as { "tests" => [...] } and splits oversized payloads
|
|
23
|
+
# into chunks of at most MAX_BATCH_SIZE records.
|
|
24
|
+
#
|
|
25
|
+
# @param tests [Array<Hash>]
|
|
26
|
+
# @return [Boolean] true when every batch succeeds (or input is empty)
|
|
27
|
+
# @raise [ResponseError] on the first non-2xx batch response
|
|
28
|
+
def post_tests(tests)
|
|
29
|
+
tests.each_slice(MAX_BATCH_SIZE) do |batch|
|
|
30
|
+
response = HTTParty.post(
|
|
31
|
+
"#{url.to_s.chomp('/')}#{TESTS_PATH}",
|
|
32
|
+
body: { tests: batch }.to_json,
|
|
33
|
+
headers: {
|
|
34
|
+
"X-Gitlab-Token" => token,
|
|
35
|
+
"Content-Type" => "application/json"
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
raise ResponseError, "Observer request failed with status #{response.code}: #{response.body}" unless response.success?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
attr_reader :url, :token
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "logger"
|
|
3
4
|
require "singleton"
|
|
4
5
|
|
|
5
6
|
module GitlabQuality
|
|
@@ -18,13 +19,9 @@ module GitlabQuality
|
|
|
18
19
|
end
|
|
19
20
|
end
|
|
20
21
|
|
|
21
|
-
attr_reader :initial_run
|
|
22
22
|
attr_accessor :run_type,
|
|
23
|
-
:
|
|
24
|
-
:
|
|
25
|
-
:clickhouse_table_name,
|
|
26
|
-
:clickhouse_username,
|
|
27
|
-
:clickhouse_password
|
|
23
|
+
:observer_url,
|
|
24
|
+
:observer_token
|
|
28
25
|
attr_writer :extra_rspec_metadata_keys,
|
|
29
26
|
:skip_record_proc,
|
|
30
27
|
:test_retried_proc,
|
|
@@ -32,44 +29,13 @@ module GitlabQuality
|
|
|
32
29
|
:spec_file_path_prefix,
|
|
33
30
|
:logger
|
|
34
31
|
|
|
35
|
-
#
|
|
36
|
-
|
|
37
|
-
# Additional columns to be created in the table if initial_run setup is used
|
|
38
|
-
# Columns should be defined in the format used for ALTER TABLE query, example;
|
|
39
|
-
# [
|
|
40
|
-
# "feature_category LowCardinality(String) DEFAULT ''",
|
|
41
|
-
# "level LowCardinality(String) DEFAULT ''"
|
|
42
|
-
# ]
|
|
43
|
-
#
|
|
44
|
-
# @param columns [Array]
|
|
45
|
-
# @return [Array]
|
|
46
|
-
def extra_metadata_columns=(columns)
|
|
47
|
-
@extra_metadata_columns = columns
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# rubocop:enable Style/TrivialAccessors
|
|
51
|
-
|
|
52
|
-
# Whether ClickHouse export is configured
|
|
32
|
+
# Whether observer export is configured
|
|
53
33
|
#
|
|
54
34
|
# Export is considered enabled when all required attributes are set
|
|
55
35
|
#
|
|
56
36
|
# @return [Boolean]
|
|
57
|
-
def
|
|
58
|
-
[
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# Marks execution as initial run and performs setup tasks before running tests, like creating database in ClickHouse
|
|
62
|
-
#
|
|
63
|
-
# @return [Boolean]
|
|
64
|
-
def initial_run!
|
|
65
|
-
@initial_run = true
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
# Additional metadata columns used during initial table creation
|
|
69
|
-
#
|
|
70
|
-
# @return [Array]
|
|
71
|
-
def extra_metadata_columns
|
|
72
|
-
@extra_metadata_columns ||= []
|
|
37
|
+
def observer_configured?
|
|
38
|
+
[observer_url, observer_token].none? { |value| value.nil? || value.to_s.empty? }
|
|
73
39
|
end
|
|
74
40
|
|
|
75
41
|
# Extra rspec metadata keys to include in exported metrics
|
|
@@ -1,35 +1,37 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'logger'
|
|
3
4
|
require 'active_support/core_ext/object/blank'
|
|
4
5
|
|
|
6
|
+
require_relative 'config'
|
|
7
|
+
require_relative 'formatter'
|
|
8
|
+
|
|
5
9
|
module GitlabQuality
|
|
6
10
|
module TestTooling
|
|
7
11
|
module TestMetricsExporter
|
|
8
12
|
class ConfigHelper
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
GLCI_CLICKHOUSE_METRICS_PASSWORD
|
|
13
|
-
GLCI_CLICKHOUSE_METRICS_DB
|
|
14
|
-
GLCI_CLICKHOUSE_METRICS_TABLE
|
|
15
|
-
GLCI_CLICKHOUSE_SHARED_DB
|
|
13
|
+
REQUIRED_OBSERVER_ENV_VARS = %w[
|
|
14
|
+
GLCI_OBSERVER_URL
|
|
15
|
+
GLCI_OBSERVER_AUTH_TOKEN
|
|
16
16
|
].freeze
|
|
17
17
|
|
|
18
18
|
class << self
|
|
19
19
|
def configure!(run_type = test_run_type)
|
|
20
|
-
return unless ENV.fetch("CI", nil) && ENV
|
|
20
|
+
return unless ENV.fetch("CI", nil) && ENV.fetch("GLCI_EXPORT_TEST_METRICS", "true") == "true" && run_type
|
|
21
21
|
|
|
22
22
|
RSpec.configure do |rspec_config|
|
|
23
23
|
next if rspec_config.dry_run?
|
|
24
24
|
|
|
25
25
|
Config.configure do |exporter_config|
|
|
26
26
|
self.logger = exporter_config.logger
|
|
27
|
-
next
|
|
27
|
+
next warn_missing_observer_variables unless observer_env_vars_present?
|
|
28
28
|
|
|
29
29
|
yield(exporter_config) if block_given?
|
|
30
30
|
configure_exporter!(exporter_config, run_type)
|
|
31
31
|
|
|
32
32
|
rspec_config.add_formatter Formatter
|
|
33
|
+
|
|
34
|
+
logger.info("Test metrics export is enabled for run type: #{run_type}")
|
|
33
35
|
end
|
|
34
36
|
end
|
|
35
37
|
end
|
|
@@ -42,59 +44,30 @@ module GitlabQuality
|
|
|
42
44
|
@logger ||= Logger.new($stdout)
|
|
43
45
|
end
|
|
44
46
|
|
|
45
|
-
def
|
|
46
|
-
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def owner_records
|
|
50
|
-
@owner_records ||= GitlabQuality::TestTooling::CodeCoverage::ClickHouse::CategoryOwnersTable.new(
|
|
51
|
-
database: ENV.fetch("GLCI_CLICKHOUSE_SHARED_DB", nil),
|
|
52
|
-
url: clickhouse_url,
|
|
53
|
-
username: clickhouse_username,
|
|
54
|
-
password: clickhouse_password
|
|
55
|
-
).owner_records
|
|
56
|
-
rescue StandardError => e
|
|
57
|
-
logger.error("Failed to retrieve owner data: #{e}")
|
|
58
|
-
@owner_records = {}
|
|
47
|
+
def observer_env_vars_present?
|
|
48
|
+
REQUIRED_OBSERVER_ENV_VARS.all? { |var| ENV.fetch(var, nil) && !ENV[var].empty? }
|
|
59
49
|
end
|
|
60
50
|
|
|
61
51
|
def configure_exporter!(config, run_type)
|
|
62
52
|
config.run_type = run_type
|
|
63
53
|
config.custom_metrics_proc = custom_metrics_proc
|
|
64
54
|
|
|
65
|
-
|
|
55
|
+
configure_observer!(config)
|
|
66
56
|
end
|
|
67
57
|
|
|
68
|
-
def
|
|
69
|
-
config.
|
|
70
|
-
config.
|
|
71
|
-
config.clickhouse_url = clickhouse_url
|
|
72
|
-
config.clickhouse_username = clickhouse_username
|
|
73
|
-
config.clickhouse_password = clickhouse_password
|
|
58
|
+
def configure_observer!(config)
|
|
59
|
+
config.observer_url = observer_url
|
|
60
|
+
config.observer_token = observer_token
|
|
74
61
|
end
|
|
75
62
|
|
|
76
|
-
def
|
|
77
|
-
missing =
|
|
63
|
+
def warn_missing_observer_variables
|
|
64
|
+
missing = REQUIRED_OBSERVER_ENV_VARS.reject { |var| ENV.fetch(var, nil) && !ENV[var].empty? }
|
|
78
65
|
logger.warn("Test metrics export is enabled but missing environment variables: #{missing.join(', ')}")
|
|
79
66
|
end
|
|
80
67
|
|
|
81
68
|
def custom_metrics_proc
|
|
82
|
-
proc do |
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
owners = if feature_category.blank?
|
|
86
|
-
logger.warn("Example '#{example.description}' is missing feature category metadata!")
|
|
87
|
-
{}
|
|
88
|
-
elsif unowned?(feature_category)
|
|
89
|
-
# currently will default to shared or tooling
|
|
90
|
-
{ group: feature_category, stage: feature_category, section: feature_category }
|
|
91
|
-
else
|
|
92
|
-
owner_records.fetch(feature_category.to_s, {}).tap do |o|
|
|
93
|
-
logger.warn("Feature category '#{feature_category}' has no owner data") if o.empty?
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
{ pipeline_type: pipeline_type, ci_pipeline_id: ci_pipeline_id, **owners }
|
|
69
|
+
proc do |_example|
|
|
70
|
+
{ pipeline_type: pipeline_type, ci_pipeline_id: ci_pipeline_id }
|
|
98
71
|
end
|
|
99
72
|
end
|
|
100
73
|
|
|
@@ -120,26 +93,16 @@ module GitlabQuality
|
|
|
120
93
|
end
|
|
121
94
|
end
|
|
122
95
|
|
|
123
|
-
def unowned?(feature_category)
|
|
124
|
-
GitlabQuality::TestTooling::CodeCoverage::ClickHouse::CategoryOwnersTable::KNOWN_UNOWNED.include?(
|
|
125
|
-
feature_category.to_s
|
|
126
|
-
)
|
|
127
|
-
end
|
|
128
|
-
|
|
129
96
|
def test_run_type
|
|
130
97
|
@run_type ||= ENV.fetch("GLCI_TEST_METRICS_RUN_TYPE", nil)
|
|
131
98
|
end
|
|
132
99
|
|
|
133
|
-
def
|
|
134
|
-
ENV.fetch("
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def clickhouse_username
|
|
138
|
-
ENV.fetch("GLCI_CLICKHOUSE_METRICS_USERNAME", nil)
|
|
100
|
+
def observer_url
|
|
101
|
+
ENV.fetch("GLCI_OBSERVER_URL", nil)
|
|
139
102
|
end
|
|
140
103
|
|
|
141
|
-
def
|
|
142
|
-
ENV.fetch("
|
|
104
|
+
def observer_token
|
|
105
|
+
ENV.fetch("GLCI_OBSERVER_AUTH_TOKEN", nil)
|
|
143
106
|
end
|
|
144
107
|
|
|
145
108
|
def ci_pipeline_id
|
|
@@ -2,27 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
require "rspec/core/formatters/base_formatter"
|
|
4
4
|
|
|
5
|
+
require_relative "test_metrics"
|
|
6
|
+
require_relative "client"
|
|
7
|
+
|
|
5
8
|
module GitlabQuality
|
|
6
9
|
module TestTooling
|
|
7
10
|
module TestMetricsExporter
|
|
8
11
|
class Formatter < RSpec::Core::Formatters::BaseFormatter
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
RSpec::Core::Formatters.register(self, :start, :stop)
|
|
12
|
+
RSpec::Core::Formatters.register(self, :stop)
|
|
12
13
|
|
|
13
14
|
LOG_PREFIX = "[MetricsExporter]"
|
|
14
15
|
|
|
15
|
-
def start(_notification)
|
|
16
|
-
return unless config.initial_run
|
|
17
|
-
|
|
18
|
-
logger.info("#{LOG_PREFIX} Running initial setup for metrics export")
|
|
19
|
-
raise "Initial setup is enabled, but clickhouse configuration is missing!" unless config.clickhouse_configured?
|
|
20
|
-
|
|
21
|
-
create_clickhouse_metrics_table
|
|
22
|
-
rescue StandardError => e
|
|
23
|
-
logger.error("#{LOG_PREFIX} Error occurred during initial setup: #{e.message}")
|
|
24
|
-
end
|
|
25
|
-
|
|
26
16
|
def stop(notification)
|
|
27
17
|
logger.debug("#{LOG_PREFIX} Starting test metrics export")
|
|
28
18
|
data = notification.examples.filter_map do |example|
|
|
@@ -32,32 +22,44 @@ module GitlabQuality
|
|
|
32
22
|
end
|
|
33
23
|
return logger.warn("#{LOG_PREFIX} No test execution records found, metrics will not be exported!") if data.empty?
|
|
34
24
|
|
|
35
|
-
|
|
25
|
+
push_to_observer(data)
|
|
36
26
|
end
|
|
37
27
|
|
|
38
28
|
private
|
|
39
29
|
|
|
30
|
+
def config
|
|
31
|
+
Config.configuration
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def logger
|
|
35
|
+
config.logger
|
|
36
|
+
end
|
|
37
|
+
|
|
40
38
|
# Single common timestamp for all exported example metrics to keep data points consistently grouped
|
|
41
39
|
#
|
|
42
40
|
# @return [String]
|
|
43
41
|
def time
|
|
44
42
|
return @time if @time
|
|
45
43
|
|
|
46
|
-
ci_created_at =
|
|
44
|
+
ci_created_at = ENV.fetch("CI_PIPELINE_CREATED_AT", nil)
|
|
47
45
|
@time = (ci_created_at ? Time.strptime(ci_created_at, '%Y-%m-%dT%H:%M:%S%z') : Time.now.utc).strftime('%Y-%m-%dT%H:%M:%S.%6N')
|
|
48
46
|
end
|
|
49
47
|
|
|
50
|
-
# Push data to
|
|
48
|
+
# Push data to observer service
|
|
51
49
|
#
|
|
52
50
|
# @param data [Array<Hash>]
|
|
53
51
|
# @return [void]
|
|
54
|
-
def
|
|
55
|
-
return logger.debug("
|
|
52
|
+
def push_to_observer(data)
|
|
53
|
+
return logger.debug("#{LOG_PREFIX} Observer configuration missing, skipping export!") unless config.observer_configured?
|
|
56
54
|
|
|
57
|
-
|
|
58
|
-
logger.info("#{LOG_PREFIX} Successfully pushed #{data.size} entries to
|
|
55
|
+
observer_client.post_tests(data)
|
|
56
|
+
logger.info("#{LOG_PREFIX} Successfully pushed #{data.size} entries to Observer!")
|
|
59
57
|
rescue StandardError => e
|
|
60
|
-
logger.error("#{LOG_PREFIX} Error occurred while pushing metrics to
|
|
58
|
+
logger.error("#{LOG_PREFIX} Error occurred while pushing metrics to Observer: #{e.message}")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def observer_client
|
|
62
|
+
Client.new(url: config.observer_url, token: config.observer_token)
|
|
61
63
|
end
|
|
62
64
|
end
|
|
63
65
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gitlab_quality-test_tooling
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.15.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- GitLab Quality
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-05-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: climate_control
|
|
@@ -491,11 +491,16 @@ files:
|
|
|
491
491
|
- lib/gitlab_quality/test_tooling/code_coverage/artifacts.rb
|
|
492
492
|
- lib/gitlab_quality/test_tooling/code_coverage/category_owners.rb
|
|
493
493
|
- lib/gitlab_quality/test_tooling/code_coverage/click_house/category_owners_table.rb
|
|
494
|
+
- lib/gitlab_quality/test_tooling/code_coverage/click_house/client.rb
|
|
494
495
|
- lib/gitlab_quality/test_tooling/code_coverage/click_house/coverage_metrics_table.rb
|
|
496
|
+
- lib/gitlab_quality/test_tooling/code_coverage/click_house/per_test_coverage_table.rb
|
|
495
497
|
- lib/gitlab_quality/test_tooling/code_coverage/click_house/table.rb
|
|
496
498
|
- lib/gitlab_quality/test_tooling/code_coverage/click_house/test_file_mappings_table.rb
|
|
499
|
+
- lib/gitlab_quality/test_tooling/code_coverage/click_house/test_health_risk_aggregation.sql
|
|
500
|
+
- lib/gitlab_quality/test_tooling/code_coverage/click_house/test_health_risk_aggregator.rb
|
|
497
501
|
- lib/gitlab_quality/test_tooling/code_coverage/coverage_data.rb
|
|
498
502
|
- lib/gitlab_quality/test_tooling/code_coverage/lcov_file.rb
|
|
503
|
+
- lib/gitlab_quality/test_tooling/code_coverage/per_test_coverage_data.rb
|
|
499
504
|
- lib/gitlab_quality/test_tooling/code_coverage/responsibility_classifier.rb
|
|
500
505
|
- lib/gitlab_quality/test_tooling/code_coverage/responsibility_patterns_config.rb
|
|
501
506
|
- lib/gitlab_quality/test_tooling/code_coverage/rspec_report.rb
|
|
@@ -588,11 +593,11 @@ files:
|
|
|
588
593
|
- lib/gitlab_quality/test_tooling/test_meta/test_meta_updater.rb
|
|
589
594
|
- lib/gitlab_quality/test_tooling/test_metric/json_test_metric.rb
|
|
590
595
|
- lib/gitlab_quality/test_tooling/test_metrics/json_test_metric_collection.rb
|
|
596
|
+
- lib/gitlab_quality/test_tooling/test_metrics_exporter/client.rb
|
|
591
597
|
- lib/gitlab_quality/test_tooling/test_metrics_exporter/config.rb
|
|
592
598
|
- lib/gitlab_quality/test_tooling/test_metrics_exporter/config_helper.rb
|
|
593
599
|
- lib/gitlab_quality/test_tooling/test_metrics_exporter/formatter.rb
|
|
594
600
|
- lib/gitlab_quality/test_tooling/test_metrics_exporter/test_metrics.rb
|
|
595
|
-
- lib/gitlab_quality/test_tooling/test_metrics_exporter/utils.rb
|
|
596
601
|
- lib/gitlab_quality/test_tooling/test_quarantine/quarantine_formatter.rb
|
|
597
602
|
- lib/gitlab_quality/test_tooling/test_quarantine/quarantine_helper.rb
|
|
598
603
|
- lib/gitlab_quality/test_tooling/test_result/base_test_result.rb
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module GitlabQuality
|
|
4
|
-
module TestTooling
|
|
5
|
-
module TestMetricsExporter
|
|
6
|
-
module Utils
|
|
7
|
-
# Instance of metrics exporter configuration
|
|
8
|
-
#
|
|
9
|
-
# @return [Config]
|
|
10
|
-
def config
|
|
11
|
-
Config.configuration
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
# Configured logger instance
|
|
15
|
-
#
|
|
16
|
-
# @return [Logger]
|
|
17
|
-
def logger
|
|
18
|
-
config.logger
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
# Configured clickhouse client
|
|
22
|
-
#
|
|
23
|
-
# @return [ClickHouse::Client]
|
|
24
|
-
def clickhouse_client
|
|
25
|
-
ClickHouse::Client.new(
|
|
26
|
-
url: config.clickhouse_url,
|
|
27
|
-
database: config.clickhouse_database,
|
|
28
|
-
username: config.clickhouse_username,
|
|
29
|
-
password: config.clickhouse_password,
|
|
30
|
-
logger: logger
|
|
31
|
-
)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
# Create table for metrics export using current ClickHouse configuration
|
|
35
|
-
#
|
|
36
|
-
# This method is mostly for schema documentation but it can be used together with initial_run! method in
|
|
37
|
-
#
|
|
38
|
-
# @return [void]
|
|
39
|
-
def create_clickhouse_metrics_table
|
|
40
|
-
table_name = config.clickhouse_table_name
|
|
41
|
-
|
|
42
|
-
clickhouse_client.query(<<~SQL)
|
|
43
|
-
CREATE TABLE IF NOT EXISTS #{table_name}
|
|
44
|
-
(
|
|
45
|
-
timestamp DateTime64(6, 'UTC'),
|
|
46
|
-
id String,
|
|
47
|
-
name String,
|
|
48
|
-
hash String,
|
|
49
|
-
file_path String,
|
|
50
|
-
status LowCardinality(String),
|
|
51
|
-
run_time UInt32,
|
|
52
|
-
location String,
|
|
53
|
-
quarantined Bool,
|
|
54
|
-
test_retried Bool,
|
|
55
|
-
feature_category LowCardinality(String) DEFAULT 'unknown',
|
|
56
|
-
run_type LowCardinality(String) DEFAULT 'unknown',
|
|
57
|
-
spec_file_path_prefix LowCardinality(String) DEFAULT '',
|
|
58
|
-
ci_project_id UInt32,
|
|
59
|
-
ci_job_name LowCardinality(String),
|
|
60
|
-
ci_job_id UInt64,
|
|
61
|
-
ci_pipeline_id UInt64,
|
|
62
|
-
ci_merge_request_iid UInt32 DEFAULT 0,
|
|
63
|
-
ci_project_path LowCardinality(String),
|
|
64
|
-
ci_branch String,
|
|
65
|
-
ci_target_branch LowCardinality(String),
|
|
66
|
-
ci_server_url LowCardinality(String) DEFAULT 'https://gitlab.com',
|
|
67
|
-
exception_class String DEFAULT '',
|
|
68
|
-
exception_classes Array(String) DEFAULT [],
|
|
69
|
-
failure_exception String DEFAULT ''
|
|
70
|
-
)
|
|
71
|
-
ENGINE = MergeTree()
|
|
72
|
-
PARTITION BY toYYYYMM(timestamp)
|
|
73
|
-
ORDER BY (ci_project_path, status, run_type, feature_category, file_path, timestamp, ci_pipeline_id)
|
|
74
|
-
SETTINGS index_granularity = 8192;
|
|
75
|
-
SQL
|
|
76
|
-
return if config.extra_metadata_columns.empty?
|
|
77
|
-
|
|
78
|
-
clickhouse_client.query(
|
|
79
|
-
"ALTER TABLE #{table_name} #{config.extra_metadata_columns.map { |column| "ADD COLUMN IF NOT EXISTS #{column}" }.join(', ')};"
|
|
80
|
-
)
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
module_function :config, :logger, :clickhouse_client
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
end
|