From 613bb9c2bac7f48720a3e12e9581dc78cac5272f Mon Sep 17 00:00:00 2001 From: Pedro Pombeiro <noreply@pedro.pombei.ro> Date: Fri, 10 Nov 2023 11:01:37 +0000 Subject: [PATCH] Add supporting classes for ClickHouse migrations Inspired by the ActiveRecord migration classes --- .../main/20230705124511_create_events.sql | 16 -- ...230707151359_create_ci_finished_builds.sql | 33 --- ..._aggregated_queueing_delay_percentiles.sql | 11 - ...2_create_contribution_analytics_events.sql | 13 - ...ion_analytics_events_materialized_view.sql | 16 -- .../20230808070520_create_events_cursor.sql | 9 - ...gregated_queueing_delay_percentiles_mv.sql | 12 - .../migrate/20230705124511_create_events.rb | 30 +++ ...0230707151359_create_ci_finished_builds.rb | 47 ++++ ...s_aggregated_queueing_delay_percentiles.rb | 25 ++ ...32_create_contribution_analytics_events.rb | 27 ++ ...tion_analytics_events_materialized_view.rb | 30 +++ .../20230808070520_create_sync_cursors.rb | 23 ++ ...ggregated_queueing_delay_percentiles_mv.rb | 26 ++ ...2300_modify_ci_finished_builds_settings.rb | 15 ++ lib/click_house/migration.rb | 89 +++++++ .../migration_support/migration_context.rb | 94 +++++++ .../migration_support/migration_error.rb | 54 ++++ lib/click_house/migration_support/migrator.rb | 160 ++++++++++++ .../migration_support/schema_migration.rb | 71 ++++++ lib/tasks/gitlab/click_house/migration.rake | 64 +++++ rubocop/rubocop-code_reuse.yml | 1 + .../migration_context_spec.rb | 233 ++++++++++++++++++ .../drop_table/1_create_some_table.rb | 14 ++ .../drop_table/2_drop_some_table.rb | 11 + .../duplicate_name/1_create_some_table.rb | 14 ++ .../duplicate_name/2_create_some_table.rb | 14 ++ .../duplicate_version/1_create_some_table.rb | 14 ++ .../duplicate_version/1_drop_some_table.rb | 11 + .../1_migration_with_error.rb | 9 + .../1_create_some_table_on_main_db.rb | 15 ++ .../2_create_some_table_on_another_db.rb | 16 ++ .../3_change_some_table_on_main_db.rb | 11 + .../1_create_some_table.rb | 14 ++ .../1_create_some_table.rb | 16 ++ .../1_create_some_table.rb | 20 ++ spec/support/database/click_house/hooks.rb | 48 ++-- spec/support/helpers/click_house_helpers.rb | 74 ++++++ .../gitlab/click_house/migration_rake_spec.rb | 118 +++++++++ spec/tooling/quality/test_level_spec.rb | 4 +- tooling/quality/test_level.rb | 1 + 41 files changed, 1396 insertions(+), 127 deletions(-) delete mode 100644 db/click_house/main/20230705124511_create_events.sql delete mode 100644 db/click_house/main/20230707151359_create_ci_finished_builds.sql delete mode 100644 db/click_house/main/20230719101806_create_ci_finished_builds_aggregated_queueing_delay_percentiles.sql delete mode 100644 db/click_house/main/20230724064832_create_contribution_analytics_events.sql delete mode 100644 db/click_house/main/20230724064918_contribution_analytics_events_materialized_view.sql delete mode 100644 db/click_house/main/20230808070520_create_events_cursor.sql delete mode 100644 db/click_house/main/20230808140217_create_ci_finished_builds_aggregated_queueing_delay_percentiles_mv.sql create mode 100644 db/click_house/migrate/20230705124511_create_events.rb create mode 100644 db/click_house/migrate/20230707151359_create_ci_finished_builds.rb create mode 100644 db/click_house/migrate/20230719101806_create_ci_finished_builds_aggregated_queueing_delay_percentiles.rb create mode 100644 db/click_house/migrate/20230724064832_create_contribution_analytics_events.rb create mode 100644 db/click_house/migrate/20230724064918_create_contribution_analytics_events_materialized_view.rb create mode 100644 db/click_house/migrate/20230808070520_create_sync_cursors.rb create mode 100644 db/click_house/migrate/20230808140217_create_ci_finished_builds_aggregated_queueing_delay_percentiles_mv.rb create mode 100644 db/click_house/migrate/20231106202300_modify_ci_finished_builds_settings.rb create mode 100644 lib/click_house/migration.rb create mode 100644 lib/click_house/migration_support/migration_context.rb create mode 100644 lib/click_house/migration_support/migration_error.rb create mode 100644 lib/click_house/migration_support/migrator.rb create mode 100644 lib/click_house/migration_support/schema_migration.rb create mode 100644 lib/tasks/gitlab/click_house/migration.rake create mode 100644 spec/click_house/migration_support/migration_context_spec.rb create mode 100644 spec/fixtures/click_house/migrations/drop_table/1_create_some_table.rb create mode 100644 spec/fixtures/click_house/migrations/drop_table/2_drop_some_table.rb create mode 100644 spec/fixtures/click_house/migrations/duplicate_name/1_create_some_table.rb create mode 100644 spec/fixtures/click_house/migrations/duplicate_name/2_create_some_table.rb create mode 100644 spec/fixtures/click_house/migrations/duplicate_version/1_create_some_table.rb create mode 100644 spec/fixtures/click_house/migrations/duplicate_version/1_drop_some_table.rb create mode 100644 spec/fixtures/click_house/migrations/migration_with_error/1_migration_with_error.rb create mode 100644 spec/fixtures/click_house/migrations/migrations_over_multiple_databases/1_create_some_table_on_main_db.rb create mode 100644 spec/fixtures/click_house/migrations/migrations_over_multiple_databases/2_create_some_table_on_another_db.rb create mode 100644 spec/fixtures/click_house/migrations/migrations_over_multiple_databases/3_change_some_table_on_main_db.rb create mode 100644 spec/fixtures/click_house/migrations/plain_table_creation/1_create_some_table.rb create mode 100644 spec/fixtures/click_house/migrations/plain_table_creation_on_invalid_database/1_create_some_table.rb create mode 100644 spec/fixtures/click_house/migrations/table_creation_with_down_method/1_create_some_table.rb create mode 100644 spec/support/helpers/click_house_helpers.rb create mode 100644 spec/tasks/gitlab/click_house/migration_rake_spec.rb diff --git a/db/click_house/main/20230705124511_create_events.sql b/db/click_house/main/20230705124511_create_events.sql deleted file mode 100644 index 8af45443e4ce4..0000000000000 --- a/db/click_house/main/20230705124511_create_events.sql +++ /dev/null @@ -1,16 +0,0 @@ -CREATE TABLE events -( - id UInt64 DEFAULT 0, - path String DEFAULT '', - author_id UInt64 DEFAULT 0, - target_id UInt64 DEFAULT 0, - target_type LowCardinality(String) DEFAULT '', - action UInt8 DEFAULT 0, - deleted UInt8 DEFAULT 0, - created_at DateTime64(6, 'UTC') DEFAULT now(), - updated_at DateTime64(6, 'UTC') DEFAULT now() -) -ENGINE = ReplacingMergeTree(updated_at, deleted) -PRIMARY KEY (id) -ORDER BY (id) -PARTITION BY toYear(created_at) diff --git a/db/click_house/main/20230707151359_create_ci_finished_builds.sql b/db/click_house/main/20230707151359_create_ci_finished_builds.sql deleted file mode 100644 index 9fd17e1968fd1..0000000000000 --- a/db/click_house/main/20230707151359_create_ci_finished_builds.sql +++ /dev/null @@ -1,33 +0,0 @@ --- source table for CI analytics, almost useless on it's own, but it's a basis for creating materialized views -CREATE TABLE ci_finished_builds -( - id UInt64 DEFAULT 0, - project_id UInt64 DEFAULT 0, - pipeline_id UInt64 DEFAULT 0, - status LowCardinality(String) DEFAULT '', - - --- Fields to calculate timings - created_at DateTime64(6, 'UTC') DEFAULT now(), - queued_at DateTime64(6, 'UTC') DEFAULT now(), - finished_at DateTime64(6, 'UTC') DEFAULT now(), - started_at DateTime64(6, 'UTC') DEFAULT now(), - - runner_id UInt64 DEFAULT 0, - runner_manager_system_xid String DEFAULT '', - - --- Runner fields - runner_run_untagged Boolean DEFAULT FALSE, - runner_type UInt8 DEFAULT 0, - runner_manager_version LowCardinality(String) DEFAULT '', - runner_manager_revision LowCardinality(String) DEFAULT '', - runner_manager_platform LowCardinality(String) DEFAULT '', - runner_manager_architecture LowCardinality(String) DEFAULT '', - - --- Materialized columns - duration Int64 MATERIALIZED age('ms', started_at, finished_at), - queueing_duration Int64 MATERIALIZED age('ms', queued_at, started_at) - --- This table is incomplete, we'll add more fields before starting the data migration -) -ENGINE = ReplacingMergeTree -- Using ReplacingMergeTree just in case we accidentally insert the same data twice -ORDER BY (status, runner_type, project_id, finished_at, id) -PARTITION BY toYear(finished_at); diff --git a/db/click_house/main/20230719101806_create_ci_finished_builds_aggregated_queueing_delay_percentiles.sql b/db/click_house/main/20230719101806_create_ci_finished_builds_aggregated_queueing_delay_percentiles.sql deleted file mode 100644 index 0b05c3a37f63a..0000000000000 --- a/db/click_house/main/20230719101806_create_ci_finished_builds_aggregated_queueing_delay_percentiles.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE ci_finished_builds_aggregated_queueing_delay_percentiles -( - status LowCardinality(String) DEFAULT '', - runner_type UInt8 DEFAULT 0, - started_at_bucket DateTime64(6, 'UTC') DEFAULT now(), - - count_builds AggregateFunction(count), - queueing_duration_quantile AggregateFunction(quantile, Int64) -) -ENGINE = AggregatingMergeTree() -ORDER BY (started_at_bucket, status, runner_type); diff --git a/db/click_house/main/20230724064832_create_contribution_analytics_events.sql b/db/click_house/main/20230724064832_create_contribution_analytics_events.sql deleted file mode 100644 index 7867897e897d3..0000000000000 --- a/db/click_house/main/20230724064832_create_contribution_analytics_events.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE TABLE contribution_analytics_events -( - id UInt64 DEFAULT 0, - path String DEFAULT '', - author_id UInt64 DEFAULT 0, - target_type LowCardinality(String) DEFAULT '', - action UInt8 DEFAULT 0, - created_at Date DEFAULT toDate(now()), - updated_at DateTime64(6, 'UTC') DEFAULT now() -) - ENGINE = MergeTree - ORDER BY (path, created_at, author_id, id) - PARTITION BY toYear(created_at); diff --git a/db/click_house/main/20230724064918_contribution_analytics_events_materialized_view.sql b/db/click_house/main/20230724064918_contribution_analytics_events_materialized_view.sql deleted file mode 100644 index 669b03ce0f3b3..0000000000000 --- a/db/click_house/main/20230724064918_contribution_analytics_events_materialized_view.sql +++ /dev/null @@ -1,16 +0,0 @@ -CREATE MATERIALIZED VIEW contribution_analytics_events_mv -TO contribution_analytics_events -AS -SELECT - id, - argMax(path, events.updated_at) as path, - argMax(author_id, events.updated_at) as author_id, - argMax(target_type, events.updated_at) as target_type, - argMax(action, events.updated_at) as action, - argMax(date(created_at), events.updated_at) as created_at, - max(events.updated_at) as updated_at -FROM events -where (("events"."action" = 5 AND "events"."target_type" = '') - OR ("events"."action" IN (1, 3, 7, 12) - AND "events"."target_type" IN ('MergeRequest', 'Issue'))) -GROUP BY id diff --git a/db/click_house/main/20230808070520_create_events_cursor.sql b/db/click_house/main/20230808070520_create_events_cursor.sql deleted file mode 100644 index effc3c64f6057..0000000000000 --- a/db/click_house/main/20230808070520_create_events_cursor.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE sync_cursors -( - table_name LowCardinality(String) DEFAULT '', - primary_key_value UInt64 DEFAULT 0, - recorded_at DateTime64(6, 'UTC') DEFAULT now() -) -ENGINE = ReplacingMergeTree(recorded_at) -ORDER BY (table_name) -PRIMARY KEY (table_name) diff --git a/db/click_house/main/20230808140217_create_ci_finished_builds_aggregated_queueing_delay_percentiles_mv.sql b/db/click_house/main/20230808140217_create_ci_finished_builds_aggregated_queueing_delay_percentiles_mv.sql deleted file mode 100644 index 504e2d876091a..0000000000000 --- a/db/click_house/main/20230808140217_create_ci_finished_builds_aggregated_queueing_delay_percentiles_mv.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE MATERIALIZED VIEW ci_finished_builds_aggregated_queueing_delay_percentiles_mv -TO ci_finished_builds_aggregated_queueing_delay_percentiles -AS -SELECT - status, - runner_type, - toStartOfInterval(started_at, INTERVAL 5 minute) AS started_at_bucket, - - countState(*) as count_builds, - quantileState(queueing_duration) AS queueing_duration_quantile -FROM ci_finished_builds -GROUP BY status, runner_type, started_at_bucket diff --git a/db/click_house/migrate/20230705124511_create_events.rb b/db/click_house/migrate/20230705124511_create_events.rb new file mode 100644 index 0000000000000..cd60ade5d4d9f --- /dev/null +++ b/db/click_house/migrate/20230705124511_create_events.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class CreateEvents < ClickHouse::Migration + def up + execute <<~SQL + CREATE TABLE IF NOT EXISTS events + ( + id UInt64 DEFAULT 0, + path String DEFAULT '', + author_id UInt64 DEFAULT 0, + target_id UInt64 DEFAULT 0, + target_type LowCardinality(String) DEFAULT '', + action UInt8 DEFAULT 0, + deleted UInt8 DEFAULT 0, + created_at DateTime64(6, 'UTC') DEFAULT now(), + updated_at DateTime64(6, 'UTC') DEFAULT now() + ) + ENGINE = ReplacingMergeTree(updated_at, deleted) + PRIMARY KEY (id) + ORDER BY (id) + PARTITION BY toYear(created_at) + SQL + end + + def down + execute <<~SQL + DROP TABLE events + SQL + end +end diff --git a/db/click_house/migrate/20230707151359_create_ci_finished_builds.rb b/db/click_house/migrate/20230707151359_create_ci_finished_builds.rb new file mode 100644 index 0000000000000..39521af8d9958 --- /dev/null +++ b/db/click_house/migrate/20230707151359_create_ci_finished_builds.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class CreateCiFinishedBuilds < ClickHouse::Migration + def up + execute <<~SQL + -- source table for CI analytics, almost useless on it's own, but it's a basis for creating materialized views + CREATE TABLE IF NOT EXISTS ci_finished_builds + ( + id UInt64 DEFAULT 0, + project_id UInt64 DEFAULT 0, + pipeline_id UInt64 DEFAULT 0, + status LowCardinality(String) DEFAULT '', + + --- Fields to calculate timings + created_at DateTime64(6, 'UTC') DEFAULT now(), + queued_at DateTime64(6, 'UTC') DEFAULT now(), + finished_at DateTime64(6, 'UTC') DEFAULT now(), + started_at DateTime64(6, 'UTC') DEFAULT now(), + + runner_id UInt64 DEFAULT 0, + runner_manager_system_xid String DEFAULT '', + + --- Runner fields + runner_run_untagged Boolean DEFAULT FALSE, + runner_type UInt8 DEFAULT 0, + runner_manager_version LowCardinality(String) DEFAULT '', + runner_manager_revision LowCardinality(String) DEFAULT '', + runner_manager_platform LowCardinality(String) DEFAULT '', + runner_manager_architecture LowCardinality(String) DEFAULT '', + + --- Materialized columns + duration Int64 MATERIALIZED age('ms', started_at, finished_at), + queueing_duration Int64 MATERIALIZED age('ms', queued_at, started_at) + --- This table is incomplete, we'll add more fields before starting the data migration + ) + ENGINE = ReplacingMergeTree -- Using ReplacingMergeTree just in case we accidentally insert the same data twice + ORDER BY (status, runner_type, project_id, finished_at, id) + PARTITION BY toYear(finished_at) + SQL + end + + def down + execute <<~SQL + DROP TABLE ci_finished_builds + SQL + end +end diff --git a/db/click_house/migrate/20230719101806_create_ci_finished_builds_aggregated_queueing_delay_percentiles.rb b/db/click_house/migrate/20230719101806_create_ci_finished_builds_aggregated_queueing_delay_percentiles.rb new file mode 100644 index 0000000000000..47934d8fe0281 --- /dev/null +++ b/db/click_house/migrate/20230719101806_create_ci_finished_builds_aggregated_queueing_delay_percentiles.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class CreateCiFinishedBuildsAggregatedQueueingDelayPercentiles < ClickHouse::Migration + def up + execute <<~SQL + CREATE TABLE IF NOT EXISTS ci_finished_builds_aggregated_queueing_delay_percentiles + ( + status LowCardinality(String) DEFAULT '', + runner_type UInt8 DEFAULT 0, + started_at_bucket DateTime64(6, 'UTC') DEFAULT now(), + + count_builds AggregateFunction(count), + queueing_duration_quantile AggregateFunction(quantile, Int64) + ) + ENGINE = AggregatingMergeTree() + ORDER BY (started_at_bucket, status, runner_type) + SQL + end + + def down + execute <<~SQL + DROP TABLE ci_finished_builds_aggregated_queueing_delay_percentiles + SQL + end +end diff --git a/db/click_house/migrate/20230724064832_create_contribution_analytics_events.rb b/db/click_house/migrate/20230724064832_create_contribution_analytics_events.rb new file mode 100644 index 0000000000000..2606ae3adc98f --- /dev/null +++ b/db/click_house/migrate/20230724064832_create_contribution_analytics_events.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class CreateContributionAnalyticsEvents < ClickHouse::Migration + def up + execute <<~SQL + CREATE TABLE IF NOT EXISTS contribution_analytics_events + ( + id UInt64 DEFAULT 0, + path String DEFAULT '', + author_id UInt64 DEFAULT 0, + target_type LowCardinality(String) DEFAULT '', + action UInt8 DEFAULT 0, + created_at Date DEFAULT toDate(now()), + updated_at DateTime64(6, 'UTC') DEFAULT now() + ) + ENGINE = MergeTree + ORDER BY (path, created_at, author_id, id) + PARTITION BY toYear(created_at); + SQL + end + + def down + execute <<~SQL + DROP TABLE contribution_analytics_events + SQL + end +end diff --git a/db/click_house/migrate/20230724064918_create_contribution_analytics_events_materialized_view.rb b/db/click_house/migrate/20230724064918_create_contribution_analytics_events_materialized_view.rb new file mode 100644 index 0000000000000..956a26d80f3e9 --- /dev/null +++ b/db/click_house/migrate/20230724064918_create_contribution_analytics_events_materialized_view.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class CreateContributionAnalyticsEventsMaterializedView < ClickHouse::Migration + def up + execute <<~SQL + CREATE MATERIALIZED VIEW IF NOT EXISTS contribution_analytics_events_mv + TO contribution_analytics_events + AS + SELECT + id, + argMax(path, events.updated_at) as path, + argMax(author_id, events.updated_at) as author_id, + argMax(target_type, events.updated_at) as target_type, + argMax(action, events.updated_at) as action, + argMax(date(created_at), events.updated_at) as created_at, + max(events.updated_at) as updated_at + FROM events + WHERE (("events"."action" = 5 AND "events"."target_type" = '') + OR ("events"."action" IN (1, 3, 7, 12) + AND "events"."target_type" IN ('MergeRequest', 'Issue'))) + GROUP BY id + SQL + end + + def down + execute <<~SQL + DROP VIEW contribution_analytics_events_mv + SQL + end +end diff --git a/db/click_house/migrate/20230808070520_create_sync_cursors.rb b/db/click_house/migrate/20230808070520_create_sync_cursors.rb new file mode 100644 index 0000000000000..7583f8ec0c523 --- /dev/null +++ b/db/click_house/migrate/20230808070520_create_sync_cursors.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class CreateSyncCursors < ClickHouse::Migration + def up + execute <<~SQL + CREATE TABLE IF NOT EXISTS sync_cursors + ( + table_name LowCardinality(String) DEFAULT '', + primary_key_value UInt64 DEFAULT 0, + recorded_at DateTime64(6, 'UTC') DEFAULT now() + ) + ENGINE = ReplacingMergeTree(recorded_at) + ORDER BY (table_name) + PRIMARY KEY (table_name) + SQL + end + + def down + execute <<~SQL + DROP TABLE sync_cursors + SQL + end +end diff --git a/db/click_house/migrate/20230808140217_create_ci_finished_builds_aggregated_queueing_delay_percentiles_mv.rb b/db/click_house/migrate/20230808140217_create_ci_finished_builds_aggregated_queueing_delay_percentiles_mv.rb new file mode 100644 index 0000000000000..cc029d48436c7 --- /dev/null +++ b/db/click_house/migrate/20230808140217_create_ci_finished_builds_aggregated_queueing_delay_percentiles_mv.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class CreateCiFinishedBuildsAggregatedQueueingDelayPercentilesMv < ClickHouse::Migration + def up + execute <<~SQL + CREATE MATERIALIZED VIEW IF NOT EXISTS ci_finished_builds_aggregated_queueing_delay_percentiles_mv + TO ci_finished_builds_aggregated_queueing_delay_percentiles + AS + SELECT + status, + runner_type, + toStartOfInterval(started_at, INTERVAL 5 minute) AS started_at_bucket, + + countState(*) as count_builds, + quantileState(queueing_duration) AS queueing_duration_quantile + FROM ci_finished_builds + GROUP BY status, runner_type, started_at_bucket + SQL + end + + def down + execute <<~SQL + DROP VIEW ci_finished_builds_aggregated_queueing_delay_percentiles_mv + SQL + end +end diff --git a/db/click_house/migrate/20231106202300_modify_ci_finished_builds_settings.rb b/db/click_house/migrate/20231106202300_modify_ci_finished_builds_settings.rb new file mode 100644 index 0000000000000..d9951725c9ba2 --- /dev/null +++ b/db/click_house/migrate/20231106202300_modify_ci_finished_builds_settings.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ModifyCiFinishedBuildsSettings < ClickHouse::Migration + def up + execute <<~SQL + ALTER TABLE ci_finished_builds MODIFY SETTING use_async_block_ids_cache = true + SQL + end + + def down + execute <<~SQL + ALTER TABLE ci_finished_builds MODIFY SETTING use_async_block_ids_cache = false + SQL + end +end diff --git a/lib/click_house/migration.rb b/lib/click_house/migration.rb new file mode 100644 index 0000000000000..410a7ec86bce0 --- /dev/null +++ b/lib/click_house/migration.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module ClickHouse + class Migration + cattr_accessor :verbose, :client_configuration + attr_accessor :name, :version + + class << self + attr_accessor :delegate + end + + def initialize(name = self.class.name, version = nil) + @name = name + @version = version + end + + self.client_configuration = ClickHouse::Client.configuration + self.verbose = true + # instantiate the delegate object after initialize is defined + self.delegate = new + + MIGRATION_FILENAME_REGEXP = /\A([0-9]+)_([_a-z0-9]*)\.?([_a-z0-9]*)?\.rb\z/ + + def database + self.class.constants.include?(:SCHEMA) ? self.class.const_get(:SCHEMA, false) : :main + end + + def execute(query) + ClickHouse::Client.execute(query, database, self.class.client_configuration) + end + + def up + self.class.delegate = self + + return unless self.class.respond_to?(:up) + + self.class.up + end + + def down + self.class.delegate = self + + return unless self.class.respond_to?(:down) + + self.class.down + end + + # Execute this migration in the named direction + def migrate(direction) + return unless respond_to?(direction) + + case direction + when :up then announce 'migrating' + when :down then announce 'reverting' + end + + time = Benchmark.measure do + exec_migration(direction) + end + + case direction + when :up then announce format("migrated (%.4fs)", time.real) + write + when :down then announce format("reverted (%.4fs)", time.real) + write + end + end + + private + + def exec_migration(direction) + # noinspection RubyCaseWithoutElseBlockInspection + case direction + when :up then up + when :down then down + end + end + + def write(text = '') + $stdout.puts(text) if verbose + end + + def announce(message) + text = "#{version} #{name}: #{message}" + length = [0, 75 - text.length].max + write format('== %s %s', text, '=' * length) + end + end +end diff --git a/lib/click_house/migration_support/migration_context.rb b/lib/click_house/migration_support/migration_context.rb new file mode 100644 index 0000000000000..6e4dd2a97c244 --- /dev/null +++ b/lib/click_house/migration_support/migration_context.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module ClickHouse + module MigrationSupport + # MigrationContext sets the context in which a migration is run. + # + # A migration context requires the path to the migrations is set + # in the +migrations_paths+ parameter. Optionally a +schema_migration+ + # class can be provided. For most applications, +SchemaMigration+ is + # sufficient. Multiple database applications need a +SchemaMigration+ + # per primary database. + class MigrationContext + attr_reader :migrations_paths, :schema_migration + + def initialize(migrations_paths, schema_migration) + @migrations_paths = migrations_paths + @schema_migration = schema_migration + end + + def up(target_version = nil, &block) + selected_migrations = block ? migrations.select(&block) : migrations + + migrate(:up, selected_migrations, target_version) + end + + def down(target_version = nil, &block) + selected_migrations = block ? migrations.select(&block) : migrations + + migrate(:down, selected_migrations, target_version) + end + + private + + def migrate(direction, selected_migrations, target_version = nil) + ClickHouse::MigrationSupport::Migrator.new( + direction, + selected_migrations, + schema_migration, + target_version + ).migrate + end + + def migrations + migrations = migration_files.map do |file| + version, name, scope = parse_migration_filename(file) + + raise ClickHouse::MigrationSupport::IllegalMigrationNameError, file unless version + + version = version.to_i + name = name.camelize + + MigrationProxy.new(name, version, file, scope) + end + + migrations.sort_by(&:version) + end + + def migration_files + paths = Array(migrations_paths) + Dir[*paths.flat_map { |path| "#{path}/**/[0-9]*_*.rb" }] + end + + def parse_migration_filename(filename) + File.basename(filename).scan(ClickHouse::Migration::MIGRATION_FILENAME_REGEXP).first + end + end + + # MigrationProxy is used to defer loading of the actual migration classes + # until they are needed + MigrationProxy = Struct.new(:name, :version, :filename, :scope) do + def initialize(name, version, filename, scope) + super + @migration = nil + end + + def basename + File.basename(filename) + end + + delegate :migrate, :announce, :write, :database, to: :migration + + private + + def migration + @migration ||= load_migration + end + + def load_migration + require(File.expand_path(filename)) + name.constantize.new(name, version) + end + end + end +end diff --git a/lib/click_house/migration_support/migration_error.rb b/lib/click_house/migration_support/migration_error.rb new file mode 100644 index 0000000000000..0638d487e3729 --- /dev/null +++ b/lib/click_house/migration_support/migration_error.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module ClickHouse + module MigrationSupport + class MigrationError < StandardError + def initialize(message = nil) + message = "\n\n#{message}\n\n" if message + super + end + end + + class IllegalMigrationNameError < MigrationError + def initialize(name = nil) + if name + super("Illegal name for migration file: #{name}\n\t(only lower case letters, numbers, and '_' allowed).") + else + super('Illegal name for migration.') + end + end + end + + IrreversibleMigration = Class.new(MigrationError) + + class DuplicateMigrationVersionError < MigrationError + def initialize(version = nil) + if version + super("Multiple migrations have the version number #{version}.") + else + super('Duplicate migration version error.') + end + end + end + + class DuplicateMigrationNameError < MigrationError + def initialize(name = nil) + if name + super("Multiple migrations have the name #{name}.") + else + super('Duplicate migration name.') + end + end + end + + class UnknownMigrationVersionError < MigrationError + def initialize(version = nil) + if version + super("No migration with version number #{version}.") + else + super('Unknown migration version.') + end + end + end + end +end diff --git a/lib/click_house/migration_support/migrator.rb b/lib/click_house/migration_support/migrator.rb new file mode 100644 index 0000000000000..5c67b3a5ff1f3 --- /dev/null +++ b/lib/click_house/migration_support/migrator.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +module ClickHouse + module MigrationSupport + class Migrator + class << self + attr_accessor :migrations_paths + end + + attr_accessor :logger + + self.migrations_paths = ["db/click_house/migrate"] + + def initialize(direction, migrations, schema_migration, target_version = nil, logger = Gitlab::AppLogger) + @direction = direction + @target_version = target_version + @migrated_versions = {} + @migrations = migrations + @schema_migration = schema_migration + @logger = logger + + validate(@migrations) + + migrations.map(&:database).uniq.each do |database| + @schema_migration.create_table(database) + end + end + + def current_version + @migrated_versions.values.flatten.max || 0 + end + + def current_migration + migrations.detect { |m| m.version == current_version } + end + alias_method :current, :current_migration + + def run + run_without_lock + end + + def migrate + migrate_without_lock + end + + def runnable + runnable = migrations[start..finish] + + if up? + runnable.reject { |m| ran?(m) } + else + # skip the last migration if we're headed down, but not ALL the way down + runnable.pop if target + runnable.find_all { |m| ran?(m) } + end + end + + def migrations + down? ? @migrations.reverse : @migrations.sort_by(&:version) + end + + def pending_migrations(database) + already_migrated = migrated(database) + + migrations.reject { |m| already_migrated.include?(m.version) } + end + + def migrated(database) + @migrated_versions[database] || load_migrated(database) + end + + def load_migrated(database) + @migrated_versions[database] = Set.new(@schema_migration.all_versions(database).map(&:to_i)) + end + + private + + # Used for running a specific migration. + def run_without_lock + migration = migrations.detect { |m| m.version == @target_version } + + raise ClickHouse::MigrationSupport::UnknownMigrationVersionError, @target_version if migration.nil? + + execute_migration(migration) + end + + # Used for running multiple migrations up to or down to a certain value. + def migrate_without_lock + raise ClickHouse::MigrationSupport::UnknownMigrationVersionError, @target_version if invalid_target? + + runnable.each(&method(:execute_migration)) # rubocop: disable Performance/MethodObjectAsBlock -- Execute through proxy + end + + def ran?(migration) + migrated(migration.database).include?(migration.version.to_i) + end + + # Return true if a valid version is not provided. + def invalid_target? + return unless @target_version + return if @target_version == 0 + + !target + end + + def execute_migration(migration) + database = migration.database + + return if down? && migrated(database).exclude?(migration.version.to_i) + return if up? && migrated(database).include?(migration.version.to_i) + + logger.info "Migrating to #{migration.name} (#{migration.version})" if logger + + migration.migrate(@direction) + record_version_state_after_migrating(database, migration.version) + rescue StandardError => e + msg = "An error has occurred, all later migrations canceled:\n\n#{e}" + raise StandardError, msg, e.backtrace + end + + def target + migrations.detect { |m| m.version == @target_version } + end + + def finish + migrations.index(target) || (migrations.size - 1) + end + + def start + up? ? 0 : (migrations.index(current) || 0) + end + + def validate(migrations) + name, = migrations.group_by(&:name).find { |_, v| v.length > 1 } + raise ClickHouse::MigrationSupport::DuplicateMigrationNameError, name if name + + version, = migrations.group_by(&:version).find { |_, v| v.length > 1 } + raise ClickHouse::MigrationSupport::DuplicateMigrationVersionError, version if version + end + + def record_version_state_after_migrating(database, version) + if down? + migrated(database).delete(version) + @schema_migration.create!(database, version: version.to_s, active: 0) + else + migrated(database) << version + @schema_migration.create!(database, version: version.to_s) + end + end + + def up? + @direction == :up + end + + def down? + @direction == :down + end + end + end +end diff --git a/lib/click_house/migration_support/schema_migration.rb b/lib/click_house/migration_support/schema_migration.rb new file mode 100644 index 0000000000000..e82debbad0de2 --- /dev/null +++ b/lib/click_house/migration_support/schema_migration.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module ClickHouse + module MigrationSupport + class SchemaMigration + class_attribute :table_name_prefix, instance_writer: false, default: '' + class_attribute :table_name_suffix, instance_writer: false, default: '' + class_attribute :schema_migrations_table_name, instance_accessor: false, default: 'schema_migrations' + + class << self + TABLE_EXISTS_QUERY = <<~SQL.squish + SELECT 1 FROM system.tables + WHERE name = {table_name: String} AND database = {database_name: String} + SQL + + def primary_key + 'version' + end + + def table_name + "#{table_name_prefix}#{schema_migrations_table_name}#{table_name_suffix}" + end + + def table_exists?(database, configuration = ClickHouse::Migration.client_configuration) + database_name = configuration.databases[database]&.database + return false unless database_name + + placeholders = { table_name: table_name, database_name: database_name } + query = ClickHouse::Client::Query.new(raw_query: TABLE_EXISTS_QUERY, placeholders: placeholders) + + ClickHouse::Client.select(query, database, configuration).any? + end + + def create_table(database, configuration = ClickHouse::Migration.client_configuration) + return if table_exists?(database, configuration) + + query = <<~SQL + CREATE TABLE #{table_name} ( + version LowCardinality(String), + active UInt8 NOT NULL DEFAULT 1, + applied_at DateTime64(6, 'UTC') NOT NULL DEFAULT now64() + ) + ENGINE = ReplacingMergeTree(applied_at) + PRIMARY KEY(version) + ORDER BY (version) + SQL + + ClickHouse::Client.execute(query, database, configuration) + end + + def all_versions(database) + query = <<~SQL + SELECT version FROM #{table_name} FINAL + WHERE active = 1 + ORDER BY (version) + SQL + + ClickHouse::Client.select(query, database, ClickHouse::Migration.client_configuration).pluck('version') + end + + def create!(database, **args) + insert_sql = <<~SQL + INSERT INTO #{table_name} (#{args.keys.join(',')}) VALUES (#{args.values.join(',')}) + SQL + + ClickHouse::Client.execute(insert_sql, database, ClickHouse::Migration.client_configuration) + end + end + end + end +end diff --git a/lib/tasks/gitlab/click_house/migration.rake b/lib/tasks/gitlab/click_house/migration.rake new file mode 100644 index 0000000000000..2c4bce65d8041 --- /dev/null +++ b/lib/tasks/gitlab/click_house/migration.rake @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +namespace :gitlab do + namespace :clickhouse do + task :prepare_schema_migration_table, [:database] => :environment do |_t, args| + require_relative '../../../../lib/click_house/migration_support/schema_migration' + + ClickHouse::MigrationSupport::SchemaMigration.create_table(args.database&.to_sym || :main) + end + + desc 'GitLab | ClickHouse | Migrate' + task migrate: [:prepare_schema_migration_table] do + migrate(:up) + end + + desc 'GitLab | ClickHouse | Rollback' + task rollback: [:prepare_schema_migration_table] do + migrate(:down) + end + + private + + def check_target_version + return unless target_version + + version = ENV['VERSION'] + + return if ClickHouse::Migration::MIGRATION_FILENAME_REGEXP.match?(version) || /\A\d+\z/.match?(version) + + raise "Invalid format of target version: `VERSION=#{version}`" + end + + def target_version + ENV['VERSION'].to_i if ENV['VERSION'] && !ENV['VERSION'].empty? + end + + def verbose + ENV['VERBOSE'] ? ENV['VERBOSE'] != 'false' : true + end + + def migrate(direction) + require_relative '../../../../lib/click_house/migration_support/schema_migration' + require_relative '../../../../lib/click_house/migration_support/migration_context' + require_relative '../../../../lib/click_house/migration_support/migrator' + + check_target_version + + scope = ENV['SCOPE'] + verbose_was = ClickHouse::Migration.verbose + ClickHouse::Migration.verbose = verbose + + migrations_paths = ClickHouse::MigrationSupport::Migrator.migrations_paths + schema_migration = ClickHouse::MigrationSupport::SchemaMigration + migration_context = ClickHouse::MigrationSupport::MigrationContext.new(migrations_paths, schema_migration) + migrations_ran = migration_context.public_send(direction, target_version) do |migration| + scope.blank? || scope == migration.scope + end + + puts('No migrations ran.') unless migrations_ran&.any? + ensure + ClickHouse::Migration.verbose = verbose_was + end + end +end diff --git a/rubocop/rubocop-code_reuse.yml b/rubocop/rubocop-code_reuse.yml index f96de5caf994d..2bd3339368da8 100644 --- a/rubocop/rubocop-code_reuse.yml +++ b/rubocop/rubocop-code_reuse.yml @@ -24,6 +24,7 @@ CodeReuse/ActiveRecord: - danger/**/*.rb - lib/backup/**/*.rb - lib/banzai/**/*.rb + - lib/click_house/migration_support/**/*.rb - lib/gitlab/background_migration/**/*.rb - lib/gitlab/cycle_analytics/**/*.rb - lib/gitlab/counters/**/*.rb diff --git a/spec/click_house/migration_support/migration_context_spec.rb b/spec/click_house/migration_support/migration_context_spec.rb new file mode 100644 index 0000000000000..9df8391270dfe --- /dev/null +++ b/spec/click_house/migration_support/migration_context_spec.rb @@ -0,0 +1,233 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_relative '../../../lib/click_house/migration_support/migration_error' + +RSpec.describe ClickHouse::MigrationSupport::MigrationContext, + click_house: :without_migrations, feature_category: :database do + include ClickHouseHelpers + + # We don't need to delete data since we don't modify Postgres data + self.use_transactional_tests = false + + let_it_be(:schema_migration) { ClickHouse::MigrationSupport::SchemaMigration } + + let(:migrations_base_dir) { 'click_house/migrations' } + let(:migrations_dir) { expand_fixture_path("#{migrations_base_dir}/#{migrations_dirname}") } + let(:migration_context) { described_class.new(migrations_dir, schema_migration) } + let(:target_version) { nil } + + after do + clear_consts(expand_fixture_path(migrations_base_dir)) + end + + describe 'performs migrations' do + subject(:migration) { migrate(target_version, migration_context) } + + describe 'when creating a table' do + let(:migrations_dirname) { 'plain_table_creation' } + + it 'creates a table' do + expect { migration }.to change { active_schema_migrations_count }.from(0).to(1) + + table_schema = describe_table('some') + expect(schema_migrations).to contain_exactly(a_hash_including(version: '1', active: 1)) + expect(table_schema).to match({ + id: a_hash_including(type: 'UInt64'), + date: a_hash_including(type: 'Date') + }) + end + end + + describe 'when dropping a table' do + let(:migrations_dirname) { 'drop_table' } + let(:target_version) { 2 } + + it 'drops table' do + migrate(1, migration_context) + expect(table_names).to include('some') + + migration + expect(table_names).not_to include('some') + end + end + + context 'when a migration raises an error' do + let(:migrations_dirname) { 'migration_with_error' } + + it 'passes the error to caller as a StandardError' do + expect { migration }.to raise_error StandardError, + "An error has occurred, all later migrations canceled:\n\nA migration error happened" + expect(schema_migrations).to be_empty + end + end + + context 'when a migration targets an unknown database' do + let(:migrations_dirname) { 'plain_table_creation_on_invalid_database' } + + it 'raises ConfigurationError' do + expect { migration }.to raise_error ClickHouse::Client::ConfigurationError, + "The database 'unknown_database' is not configured" + end + end + + context 'when migrations target multiple databases' do + let_it_be(:config) { ClickHouse::Client::Configuration.new } + let_it_be(:main_db_config) { [:main, config] } + let_it_be(:another_db_config) { [:another_db, config] } + let_it_be(:another_database_name) { 'gitlab_clickhouse_test_2' } + + let(:migrations_dirname) { 'migrations_over_multiple_databases' } + + before(:context) do + # Ensure we have a second database to run the test on + clone_database_configuration(:main, :another_db, another_database_name, config) + + with_net_connect_allowed do + ClickHouse::Client.execute("CREATE DATABASE IF NOT EXISTS #{another_database_name}", :main, config) + end + end + + after(:context) do + with_net_connect_allowed do + ClickHouse::Client.execute("DROP DATABASE #{another_database_name}", :another_db, config) + end + end + + around do |example| + clear_db(configuration: config) + + previous_config = ClickHouse::Migration.client_configuration + ClickHouse::Migration.client_configuration = config + + example.run + ensure + ClickHouse::Migration.client_configuration = previous_config + end + + def clone_database_configuration(source_db_identifier, target_db_identifier, target_db_name, target_config) + raw_config = Rails.application.config_for(:click_house) + raw_config.each do |database_identifier, db_config| + register_database(target_config, database_identifier, db_config) + end + + target_db_config = raw_config[source_db_identifier].merge(database: target_db_name) + register_database(target_config, target_db_identifier, target_db_config) + target_config.http_post_proc = ClickHouse::Client.configuration.http_post_proc + target_config.json_parser = ClickHouse::Client.configuration.json_parser + target_config.logger = ::Logger.new(IO::NULL) + end + + it 'registers migrations on respective database', :aggregate_failures do + expect { migrate(2, migration_context) } + .to change { active_schema_migrations_count(*main_db_config) }.from(0).to(1) + .and change { active_schema_migrations_count(*another_db_config) }.from(0).to(1) + + expect(schema_migrations(*another_db_config)).to contain_exactly(a_hash_including(version: '2', active: 1)) + expect(table_names(*main_db_config)).not_to include('some_on_another_db') + expect(table_names(*another_db_config)).not_to include('some') + + expect(describe_table('some', *main_db_config)).to match({ + id: a_hash_including(type: 'UInt64'), + date: a_hash_including(type: 'Date') + }) + expect(describe_table('some_on_another_db', *another_db_config)).to match({ + id: a_hash_including(type: 'UInt64'), + date: a_hash_including(type: 'Date') + }) + + expect { migrate(nil, migration_context) } + .to change { active_schema_migrations_count(*main_db_config) }.to(2) + .and not_change { active_schema_migrations_count(*another_db_config) } + + expect(schema_migrations(*main_db_config)).to match([ + a_hash_including(version: '1', active: 1), + a_hash_including(version: '3', active: 1) + ]) + expect(schema_migrations(*another_db_config)).to match_array(a_hash_including(version: '2', active: 1)) + + expect(describe_table('some', *main_db_config)).to match({ + id: a_hash_including(type: 'UInt64'), + timestamp: a_hash_including(type: 'Date') + }) + end + end + + context 'when target_version is incorrect' do + let(:target_version) { 2 } + let(:migrations_dirname) { 'plain_table_creation' } + + it 'raises UnknownMigrationVersionError' do + expect { migration }.to raise_error ClickHouse::MigrationSupport::UnknownMigrationVersionError + + expect(active_schema_migrations_count).to eq 0 + end + end + + context 'when migrations with duplicate name exist' do + let(:migrations_dirname) { 'duplicate_name' } + + it 'raises DuplicateMigrationNameError' do + expect { migration }.to raise_error ClickHouse::MigrationSupport::DuplicateMigrationNameError + + expect(active_schema_migrations_count).to eq 0 + end + end + + context 'when migrations with duplicate version exist' do + let(:migrations_dirname) { 'duplicate_version' } + + it 'raises DuplicateMigrationVersionError' do + expect { migration }.to raise_error ClickHouse::MigrationSupport::DuplicateMigrationVersionError + + expect(active_schema_migrations_count).to eq 0 + end + end + end + + describe 'performs rollbacks' do + subject(:migration) { rollback(target_version, migration_context) } + + before do + migrate(nil, migration_context) + end + + context 'when migrating back all the way to 0' do + let(:target_version) { 0 } + + context 'when down method is present' do + let(:migrations_dirname) { 'table_creation_with_down_method' } + + it 'removes migration and performs down method' do + expect(table_names).to include('some') + + expect { migration }.to change { active_schema_migrations_count }.from(1).to(0) + + expect(table_names).not_to include('some') + expect(schema_migrations).to contain_exactly(a_hash_including(version: '1', active: 0)) + end + end + + context 'when down method is missing' do + let(:migrations_dirname) { 'plain_table_creation' } + + it 'removes migration ignoring missing down method' do + expect { migration }.to change { active_schema_migrations_count }.from(1).to(0) + .and not_change { table_names & %w[some] }.from(%w[some]) + end + end + end + + context 'when target_version is incorrect' do + let(:target_version) { -1 } + let(:migrations_dirname) { 'plain_table_creation' } + + it 'raises UnknownMigrationVersionError' do + expect { migration }.to raise_error ClickHouse::MigrationSupport::UnknownMigrationVersionError + + expect(active_schema_migrations_count).to eq 1 + end + end + end +end diff --git a/spec/fixtures/click_house/migrations/drop_table/1_create_some_table.rb b/spec/fixtures/click_house/migrations/drop_table/1_create_some_table.rb new file mode 100644 index 0000000000000..14ef80cbdb743 --- /dev/null +++ b/spec/fixtures/click_house/migrations/drop_table/1_create_some_table.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# rubocop: disable Gitlab/NamespacedClass -- Fixtures do not need to be namespaced +class CreateSomeTable < ClickHouse::Migration + def up + execute <<~SQL + CREATE TABLE some ( + id UInt64, + date Date + ) ENGINE = Memory + SQL + end +end +# rubocop: enable Gitlab/NamespacedClass diff --git a/spec/fixtures/click_house/migrations/drop_table/2_drop_some_table.rb b/spec/fixtures/click_house/migrations/drop_table/2_drop_some_table.rb new file mode 100644 index 0000000000000..82045b08e21ad --- /dev/null +++ b/spec/fixtures/click_house/migrations/drop_table/2_drop_some_table.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# rubocop: disable Gitlab/NamespacedClass -- Fixtures do not need to be namespaced +class DropSomeTable < ClickHouse::Migration + def up + execute <<~SQL + DROP TABLE some + SQL + end +end +# rubocop: enable Gitlab/NamespacedClass diff --git a/spec/fixtures/click_house/migrations/duplicate_name/1_create_some_table.rb b/spec/fixtures/click_house/migrations/duplicate_name/1_create_some_table.rb new file mode 100644 index 0000000000000..14ef80cbdb743 --- /dev/null +++ b/spec/fixtures/click_house/migrations/duplicate_name/1_create_some_table.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# rubocop: disable Gitlab/NamespacedClass -- Fixtures do not need to be namespaced +class CreateSomeTable < ClickHouse::Migration + def up + execute <<~SQL + CREATE TABLE some ( + id UInt64, + date Date + ) ENGINE = Memory + SQL + end +end +# rubocop: enable Gitlab/NamespacedClass diff --git a/spec/fixtures/click_house/migrations/duplicate_name/2_create_some_table.rb b/spec/fixtures/click_house/migrations/duplicate_name/2_create_some_table.rb new file mode 100644 index 0000000000000..be6c1905502aa --- /dev/null +++ b/spec/fixtures/click_house/migrations/duplicate_name/2_create_some_table.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# rubocop: disable Gitlab/NamespacedClass -- Fixtures do not need to be namespaced +class CreateSomeTable2 < ClickHouse::Migration + def up + execute <<~SQL + CREATE TABLE some ( + id UInt64, + date Date + ) ENGINE = Memory + SQL + end +end +# rubocop: enable Gitlab/NamespacedClass diff --git a/spec/fixtures/click_house/migrations/duplicate_version/1_create_some_table.rb b/spec/fixtures/click_house/migrations/duplicate_version/1_create_some_table.rb new file mode 100644 index 0000000000000..14ef80cbdb743 --- /dev/null +++ b/spec/fixtures/click_house/migrations/duplicate_version/1_create_some_table.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# rubocop: disable Gitlab/NamespacedClass -- Fixtures do not need to be namespaced +class CreateSomeTable < ClickHouse::Migration + def up + execute <<~SQL + CREATE TABLE some ( + id UInt64, + date Date + ) ENGINE = Memory + SQL + end +end +# rubocop: enable Gitlab/NamespacedClass diff --git a/spec/fixtures/click_house/migrations/duplicate_version/1_drop_some_table.rb b/spec/fixtures/click_house/migrations/duplicate_version/1_drop_some_table.rb new file mode 100644 index 0000000000000..82045b08e21ad --- /dev/null +++ b/spec/fixtures/click_house/migrations/duplicate_version/1_drop_some_table.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# rubocop: disable Gitlab/NamespacedClass -- Fixtures do not need to be namespaced +class DropSomeTable < ClickHouse::Migration + def up + execute <<~SQL + DROP TABLE some + SQL + end +end +# rubocop: enable Gitlab/NamespacedClass diff --git a/spec/fixtures/click_house/migrations/migration_with_error/1_migration_with_error.rb b/spec/fixtures/click_house/migrations/migration_with_error/1_migration_with_error.rb new file mode 100644 index 0000000000000..b8ae3df20851d --- /dev/null +++ b/spec/fixtures/click_house/migrations/migration_with_error/1_migration_with_error.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# rubocop: disable Gitlab/NamespacedClass -- Fixtures do not need to be namespaced +class MigrationWithError < ClickHouse::Migration + def up + raise ClickHouse::Client::DatabaseError, 'A migration error happened' + end +end +# rubocop: enable Gitlab/NamespacedClass diff --git a/spec/fixtures/click_house/migrations/migrations_over_multiple_databases/1_create_some_table_on_main_db.rb b/spec/fixtures/click_house/migrations/migrations_over_multiple_databases/1_create_some_table_on_main_db.rb new file mode 100644 index 0000000000000..98d71d9507b07 --- /dev/null +++ b/spec/fixtures/click_house/migrations/migrations_over_multiple_databases/1_create_some_table_on_main_db.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# rubocop: disable Gitlab/NamespacedClass -- Fixtures do not need to be namespaced +class CreateSomeTableOnMainDb < ClickHouse::Migration + def up + execute <<~SQL + CREATE TABLE some ( + id UInt64, + date Date + ) ENGINE = MergeTree + PRIMARY KEY(id) + SQL + end +end +# rubocop: enable Gitlab/NamespacedClass diff --git a/spec/fixtures/click_house/migrations/migrations_over_multiple_databases/2_create_some_table_on_another_db.rb b/spec/fixtures/click_house/migrations/migrations_over_multiple_databases/2_create_some_table_on_another_db.rb new file mode 100644 index 0000000000000..b8cd86a67f58a --- /dev/null +++ b/spec/fixtures/click_house/migrations/migrations_over_multiple_databases/2_create_some_table_on_another_db.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# rubocop: disable Gitlab/NamespacedClass -- Fixtures do not need to be namespaced +class CreateSomeTableOnAnotherDb < ClickHouse::Migration + SCHEMA = :another_db + + def up + execute <<~SQL + CREATE TABLE some_on_another_db ( + id UInt64, + date Date + ) ENGINE = Memory + SQL + end +end +# rubocop: enable Gitlab/NamespacedClass diff --git a/spec/fixtures/click_house/migrations/migrations_over_multiple_databases/3_change_some_table_on_main_db.rb b/spec/fixtures/click_house/migrations/migrations_over_multiple_databases/3_change_some_table_on_main_db.rb new file mode 100644 index 0000000000000..9112ab79fc5f4 --- /dev/null +++ b/spec/fixtures/click_house/migrations/migrations_over_multiple_databases/3_change_some_table_on_main_db.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# rubocop: disable Gitlab/NamespacedClass -- Fixtures do not need to be namespaced +class ChangeSomeTableOnMainDb < ClickHouse::Migration + def up + execute <<~SQL + ALTER TABLE some RENAME COLUMN date to timestamp + SQL + end +end +# rubocop: enable Gitlab/NamespacedClass diff --git a/spec/fixtures/click_house/migrations/plain_table_creation/1_create_some_table.rb b/spec/fixtures/click_house/migrations/plain_table_creation/1_create_some_table.rb new file mode 100644 index 0000000000000..14ef80cbdb743 --- /dev/null +++ b/spec/fixtures/click_house/migrations/plain_table_creation/1_create_some_table.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# rubocop: disable Gitlab/NamespacedClass -- Fixtures do not need to be namespaced +class CreateSomeTable < ClickHouse::Migration + def up + execute <<~SQL + CREATE TABLE some ( + id UInt64, + date Date + ) ENGINE = Memory + SQL + end +end +# rubocop: enable Gitlab/NamespacedClass diff --git a/spec/fixtures/click_house/migrations/plain_table_creation_on_invalid_database/1_create_some_table.rb b/spec/fixtures/click_house/migrations/plain_table_creation_on_invalid_database/1_create_some_table.rb new file mode 100644 index 0000000000000..ee900ef24c5cb --- /dev/null +++ b/spec/fixtures/click_house/migrations/plain_table_creation_on_invalid_database/1_create_some_table.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# rubocop: disable Gitlab/NamespacedClass -- Fixtures do not need to be namespaced +class CreateSomeTable < ClickHouse::Migration + SCHEMA = :unknown_database + + def up + execute <<~SQL + CREATE TABLE some ( + id UInt64, + date Date + ) ENGINE = Memory + SQL + end +end +# rubocop: enable Gitlab/NamespacedClass diff --git a/spec/fixtures/click_house/migrations/table_creation_with_down_method/1_create_some_table.rb b/spec/fixtures/click_house/migrations/table_creation_with_down_method/1_create_some_table.rb new file mode 100644 index 0000000000000..7ac92b9ee38b9 --- /dev/null +++ b/spec/fixtures/click_house/migrations/table_creation_with_down_method/1_create_some_table.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# rubocop: disable Gitlab/NamespacedClass -- Fixtures do not need to be namespaced +class CreateSomeTable < ClickHouse::Migration + def up + execute <<~SQL + CREATE TABLE some ( + id UInt64, + date Date + ) ENGINE = Memory + SQL + end + + def down + execute <<~SQL + DROP TABLE some + SQL + end +end +# rubocop: enable Gitlab/NamespacedClass diff --git a/spec/support/database/click_house/hooks.rb b/spec/support/database/click_house/hooks.rb index b970d3daf84dc..c13778f9c3679 100644 --- a/spec/support/database/click_house/hooks.rb +++ b/spec/support/database/click_house/hooks.rb @@ -9,6 +9,8 @@ def truncate_tables "(SELECT '#{table}' AS table FROM #{table} LIMIT 1)" end.join(' UNION ALL ') + next if query.empty? + tables_with_data = ClickHouse::Client.select(query, db).pluck('table') tables_with_data.each do |table| ClickHouse::Client.execute("TRUNCATE TABLE #{table}", db) @@ -16,20 +18,27 @@ def truncate_tables end end - def ensure_schema - return if @ensure_schema - - ClickHouse::Client.configuration.databases.each_key do |db| + def clear_db(configuration = ClickHouse::Client.configuration) + configuration.databases.each_key do |db| # drop all tables - lookup_tables(db).each do |table| - ClickHouse::Client.execute("DROP TABLE IF EXISTS #{table}", db) + lookup_tables(db, configuration).each do |table| + ClickHouse::Client.execute("DROP TABLE IF EXISTS #{table}", db, configuration) end - # run the schema SQL files - Dir[Rails.root.join("db/click_house/#{db}/*.sql")].each do |file| - ClickHouse::Client.execute(File.read(file), db) - end + ClickHouse::MigrationSupport::SchemaMigration.create_table(db, configuration) end + end + + def ensure_schema + return if @ensure_schema + + clear_db + + # run the schema SQL files + migrations_paths = ClickHouse::MigrationSupport::Migrator.migrations_paths + schema_migration = ClickHouse::MigrationSupport::SchemaMigration + migration_context = ClickHouse::MigrationSupport::MigrationContext.new(migrations_paths, schema_migration) + migration_context.up @ensure_schema = true end @@ -38,11 +47,11 @@ def ensure_schema def tables_for(db) @tables ||= {} - @tables[db] ||= lookup_tables(db) + @tables[db] ||= lookup_tables(db) - [ClickHouse::MigrationSupport::SchemaMigration.table_name] end - def lookup_tables(db) - ClickHouse::Client.select('SHOW TABLES', db).pluck('name') + def lookup_tables(db, configuration = ClickHouse::Client.configuration) + ClickHouse::Client.select('SHOW TABLES', db, configuration).pluck('name') end end # rubocop: enable Gitlab/NamespacedClass @@ -52,10 +61,19 @@ def lookup_tables(db) config.around(:each, :click_house) do |example| with_net_connect_allowed do - click_house_test_runner.ensure_schema - click_house_test_runner.truncate_tables + was_verbose = ClickHouse::Migration.verbose + ClickHouse::Migration.verbose = false + + if example.example.metadata[:click_house] == :without_migrations + click_house_test_runner.clear_db + else + click_house_test_runner.ensure_schema + click_house_test_runner.truncate_tables + end example.run + ensure + ClickHouse::Migration.verbose = was_verbose end end end diff --git a/spec/support/helpers/click_house_helpers.rb b/spec/support/helpers/click_house_helpers.rb new file mode 100644 index 0000000000000..93d5b1d6e01c9 --- /dev/null +++ b/spec/support/helpers/click_house_helpers.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module ClickHouseHelpers + private + + def migrate(target_version, migration_context) + quietly { migration_context.up(target_version) } + end + + def rollback(target_version, migration_context) + quietly { migration_context.down(target_version) } + end + + def table_names(database = :main, configuration = ClickHouse::Client.configuration) + ClickHouse::Client.select('SHOW TABLES', database, configuration).pluck('name') + end + + def active_schema_migrations_count(database = :main, configuration = ClickHouse::Client.configuration) + query = <<~SQL + SELECT COUNT(*) AS count FROM schema_migrations FINAL WHERE active = 1 + SQL + + ClickHouse::Client.select(query, database, configuration).first['count'] + end + + def describe_table(table_name, database = :main, configuration = ClickHouse::Client.configuration) + ClickHouse::Client + .select("DESCRIBE TABLE #{table_name} FORMAT JSON", database, configuration) + .map(&:symbolize_keys) + .index_by { |h| h[:name].to_sym } + end + + def schema_migrations(database = :main, configuration = ClickHouse::Client.configuration) + ClickHouse::Client + .select('SELECT * FROM schema_migrations FINAL ORDER BY version ASC', database, configuration) + .map(&:symbolize_keys) + end + + def clear_db(configuration: ClickHouse::Client.configuration) + ClickHouseTestRunner.new.clear_db(configuration) + end + + def register_database(config, database_identifier, db_config) + config.register_database( + database_identifier, + database: db_config[:database], + url: db_config[:url], + username: db_config[:username], + password: db_config[:password], + variables: db_config[:variables] || {} + ) + end + + def clear_consts(fixtures_path) + $LOADED_FEATURES.select { |file| file.include? fixtures_path }.each do |file| + const = File.basename(file) + .scan(ClickHouse::Migration::MIGRATION_FILENAME_REGEXP)[0][1] + .camelcase + .safe_constantize + + Object.send(:remove_const, const.to_s) if const + $LOADED_FEATURES.delete(file) + end + end + + def quietly(&_block) + was_verbose = ClickHouse::Migration.verbose + ClickHouse::Migration.verbose = false + + yield + ensure + ClickHouse::Migration.verbose = was_verbose + end +end diff --git a/spec/tasks/gitlab/click_house/migration_rake_spec.rb b/spec/tasks/gitlab/click_house/migration_rake_spec.rb new file mode 100644 index 0000000000000..6b834d52e9a63 --- /dev/null +++ b/spec/tasks/gitlab/click_house/migration_rake_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'gitlab:clickhouse', click_house: :without_migrations, feature_category: :database do + include ClickHouseHelpers + + # We don't need to delete data since we don't modify Postgres data + self.use_transactional_tests = false + + let(:migrations_base_dir) { 'click_house/migrations' } + let(:migrations_dirname) { '' } + let(:migrations_dir) { expand_fixture_path("#{migrations_base_dir}/#{migrations_dirname}") } + + before(:all) do + Rake.application.rake_require 'tasks/gitlab/click_house/migration' + end + + before do + stub_env('VERBOSE', 'false') + end + + describe 'migrate' do + subject(:migration) { run_rake_task('gitlab:clickhouse:migrate') } + + let(:target_version) { nil } + + around do |example| + ClickHouse::MigrationSupport::Migrator.migrations_paths = [migrations_dir] + + example.run + + clear_consts(expand_fixture_path(migrations_base_dir)) + end + + before do + stub_env('VERSION', target_version) if target_version + end + + describe 'when creating a table' do + let(:migrations_dirname) { 'plain_table_creation' } + + it 'creates a table' do + expect { migration }.to change { active_schema_migrations_count }.from(0).to(1) + + expect(describe_table('some')).to match({ + id: a_hash_including(type: 'UInt64'), + date: a_hash_including(type: 'Date') + }) + end + end + + describe 'when dropping a table' do + let(:migrations_dirname) { 'drop_table' } + let(:target_version) { 2 } + + it 'drops table' do + stub_env('VERSION', 1) + run_rake_task('gitlab:clickhouse:migrate') + + expect(table_names).to include('some') + + stub_env('VERSION', target_version) + migration + expect(table_names).not_to include('some') + end + end + + describe 'with VERSION is invalid' do + let(:migrations_dirname) { 'plain_table_creation' } + let(:target_version) { 'invalid' } + + it { expect { migration }.to raise_error RuntimeError, 'Invalid format of target version: `VERSION=invalid`' } + end + end + + describe 'rollback' do + subject(:migration) { run_rake_task('gitlab:clickhouse:rollback') } + + let(:schema_migration) { ClickHouse::MigrationSupport::SchemaMigration } + + around do |example| + ClickHouse::MigrationSupport::Migrator.migrations_paths = [migrations_dir] + migrate(nil, ClickHouse::MigrationSupport::MigrationContext.new(migrations_dir, schema_migration)) + + example.run + + clear_consts(expand_fixture_path(migrations_base_dir)) + end + + context 'when migrating back all the way to 0' do + let(:target_version) { 0 } + + context 'when down method is present' do + let(:migrations_dirname) { 'table_creation_with_down_method' } + + it 'removes migration' do + expect(table_names).to include('some') + + migration + expect(table_names).not_to include('some') + end + end + end + end + + %w[gitlab:clickhouse:migrate].each do |task| + context "when running #{task}" do + it "does run gitlab:clickhouse:prepare_schema_migration_table before" do + expect(Rake::Task['gitlab:clickhouse:prepare_schema_migration_table']).to receive(:execute).and_return(true) + expect(Rake::Task[task]).to receive(:execute).and_return(true) + + Rake::Task['gitlab:clickhouse:prepare_schema_migration_table'].reenable + run_rake_task(task) + end + end + end +end diff --git a/spec/tooling/quality/test_level_spec.rb b/spec/tooling/quality/test_level_spec.rb index 6ccd2e46f7bb3..d7d04015b4806 100644 --- a/spec/tooling/quality/test_level_spec.rb +++ b/spec/tooling/quality/test_level_spec.rb @@ -46,7 +46,7 @@ context 'when level is unit' do it 'returns a pattern' do expect(subject.pattern(:unit)) - .to eq("spec/{bin,channels,components,config,contracts,db,dependencies,elastic,elastic_integration,experiments,factories,finders,frontend,graphql,haml_lint,helpers,initializers,lib,metrics_server,models,policies,presenters,rack_servers,replicators,routing,rubocop,scripts,serializers,services,sidekiq,sidekiq_cluster,spam,support_specs,tasks,uploaders,validators,views,workers,tooling}{,/**/}*_spec.rb") + .to eq("spec/{bin,channels,click_house,components,config,contracts,db,dependencies,elastic,elastic_integration,experiments,factories,finders,frontend,graphql,haml_lint,helpers,initializers,lib,metrics_server,models,policies,presenters,rack_servers,replicators,routing,rubocop,scripts,serializers,services,sidekiq,sidekiq_cluster,spam,support_specs,tasks,uploaders,validators,views,workers,tooling}{,/**/}*_spec.rb") end end @@ -121,7 +121,7 @@ context 'when level is unit' do it 'returns a regexp' do expect(subject.regexp(:unit)) - .to eq(%r{spec/(bin|channels|components|config|contracts|db|dependencies|elastic|elastic_integration|experiments|factories|finders|frontend|graphql|haml_lint|helpers|initializers|lib|metrics_server|models|policies|presenters|rack_servers|replicators|routing|rubocop|scripts|serializers|services|sidekiq|sidekiq_cluster|spam|support_specs|tasks|uploaders|validators|views|workers|tooling)/}) + .to eq(%r{spec/(bin|channels|click_house|components|config|contracts|db|dependencies|elastic|elastic_integration|experiments|factories|finders|frontend|graphql|haml_lint|helpers|initializers|lib|metrics_server|models|policies|presenters|rack_servers|replicators|routing|rubocop|scripts|serializers|services|sidekiq|sidekiq_cluster|spam|support_specs|tasks|uploaders|validators|views|workers|tooling)/}) end end diff --git a/tooling/quality/test_level.rb b/tooling/quality/test_level.rb index 20e00763f6543..050eb4f4dafd6 100644 --- a/tooling/quality/test_level.rb +++ b/tooling/quality/test_level.rb @@ -18,6 +18,7 @@ class TestLevel unit: %w[ bin channels + click_house components config contracts -- GitLab