diff --git a/.rubocop.yml b/.rubocop.yml index 226316cd..90c64ac7 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -40,3 +40,6 @@ Metrics/CyclomaticComplexity: Metrics/PerceivedComplexity: Max: 10 + +Metrics/AbcSize: + Enabled: false diff --git a/README.md b/README.md index 6e3f3c40..c4afc9e1 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,50 @@ Add the following line to your initializer file (`config/initializers/actual_db_ config.auto_rollback_disabled = true ``` +## Rollback Instrumentation + +ActualDbSchema emits an `ActiveSupport::Notifications` event for each successful phantom rollback: + +- Event name: `rollback_migration.actual_db_schema` +- Event is always emitted when a phantom rollback succeeds + +### Event payload + +| Field | Description | +|-------|-------------| +| `version` | Migration version that was rolled back | +| `name` | Migration class name | +| `database` | Current database name from Active Record config | +| `schema` | Tenant schema name (or `nil` for default schema) | +| `branch` | Branch associated with the migration metadata | +| `manual_mode` | Whether rollback was run in manual mode | + +### Subscribing to rollback events + +You can subscribe to rollback events in your initializer to track statistics or perform custom actions: + +```ruby +# config/initializers/actual_db_schema.rb +ActiveSupport::Notifications.subscribe(ActualDbSchema::Instrumentation::ROLLBACK_EVENT) do |_name, _start, _finish, _id, payload| + ActualDbSchema::RollbackStatsRepository.record(payload) +end +``` + +The `RollbackStatsRepository` persists rollback events to a database table (`actual_db_schema_rollback_events`) that is automatically excluded from schema dumps. + +Read aggregated stats at runtime: + +```ruby +ActualDbSchema::RollbackStatsRepository.stats +# => { total: 3, by_database: { "primary" => 3 }, by_schema: { "default" => 3 }, by_branch: { "main" => 3 } } + +ActualDbSchema::RollbackStatsRepository.total_rollbacks +# => 3 + +ActualDbSchema::RollbackStatsRepository.reset! +# Clears all recorded stats +``` + ## Automatic Phantom Migration Rollback On Branch Switch By default, the automatic rollback of migrations on branch switch is disabled. If you prefer to automatically rollback phantom migrations whenever you switch branches with `git checkout`, you can enable it in two ways: diff --git a/lib/actual_db_schema.rb b/lib/actual_db_schema.rb index 2ff99d44..a38ea7ed 100644 --- a/lib/actual_db_schema.rb +++ b/lib/actual_db_schema.rb @@ -4,7 +4,9 @@ require "active_record/migration" require "csv" require_relative "actual_db_schema/git" +require_relative "actual_db_schema/rollback_stats_repository" require_relative "actual_db_schema/configuration" +require_relative "actual_db_schema/instrumentation" require_relative "actual_db_schema/store" require_relative "actual_db_schema/version" require_relative "actual_db_schema/migration" diff --git a/lib/actual_db_schema/engine.rb b/lib/actual_db_schema/engine.rb index d51e7f88..cc1358a3 100644 --- a/lib/actual_db_schema/engine.rb +++ b/lib/actual_db_schema/engine.rb @@ -15,17 +15,18 @@ class Engine < ::Rails::Engine initializer "actual_db_schema.schema_dump_exclusions" do ActiveSupport.on_load(:active_record) do - apply_schema_dump_exclusions + ActualDbSchema::Engine.apply_schema_dump_exclusions end end def self.apply_schema_dump_exclusions - table_name = ActualDbSchema::Store::DbAdapter::TABLE_NAME - ignore_schema_dump_table(table_name) + ignore_schema_dump_table(ActualDbSchema::Store::DbAdapter::TABLE_NAME) + ignore_schema_dump_table(ActualDbSchema::RollbackStatsRepository::TABLE_NAME) return unless schema_dump_flags_supported? return unless schema_dump_connection_available? - apply_structure_dump_flags(table_name) + apply_structure_dump_flags(ActualDbSchema::Store::DbAdapter::TABLE_NAME) + apply_structure_dump_flags(ActualDbSchema::RollbackStatsRepository::TABLE_NAME) end class << self diff --git a/lib/actual_db_schema/instrumentation.rb b/lib/actual_db_schema/instrumentation.rb new file mode 100644 index 00000000..2ec8df8b --- /dev/null +++ b/lib/actual_db_schema/instrumentation.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ActualDbSchema + module Instrumentation + ROLLBACK_EVENT = "rollback.actual_db_schema" + end +end diff --git a/lib/actual_db_schema/patches/migration_context.rb b/lib/actual_db_schema/patches/migration_context.rb index 627ae6a2..2c445e53 100644 --- a/lib/actual_db_schema/patches/migration_context.rb +++ b/lib/actual_db_schema/patches/migration_context.rb @@ -37,7 +37,10 @@ def rollback_branches_for_schema(manual_mode: false, schema_name: nil, rolled_ba next unless status_up?(migration) show_info_for(migration, schema_name) if manual_mode - migrate(migration, rolled_back_migrations, schema_name) if !manual_mode || user_wants_rollback? + if !manual_mode || user_wants_rollback? + migrate(migration, rolled_back_migrations, schema_name, + manual_mode: manual_mode) + end rescue StandardError => e handle_rollback_error(migration, e, schema_name) end @@ -103,21 +106,36 @@ def show_info_for(migration, schema_name = nil) puts File.read(migration.filename) end - def migrate(migration, rolled_back_migrations, schema_name = nil) + def migrate(migration, rolled_back_migrations, schema_name = nil, manual_mode: false) migration.name = extract_class_name(migration.filename) + branch = branch_for(migration.version.to_s) message = "[ActualDbSchema]" message += " #{schema_name}:" if schema_name message += " Rolling back phantom migration #{migration.version} #{migration.name} " \ - "(from branch: #{branch_for(migration.version.to_s)})" + "(from branch: #{branch})" puts colorize(message, :gray) migrator = down_migrator_for(migration) migrator.extend(ActualDbSchema::Patches::Migrator) migrator.migrate + notify_rollback_migration(migration: migration, schema_name: schema_name, branch: branch, + manual_mode: manual_mode) rolled_back_migrations << migration end + def notify_rollback_migration(migration:, schema_name:, branch:, manual_mode:) + ActiveSupport::Notifications.instrument( + ActualDbSchema::Instrumentation::ROLLBACK_EVENT, + version: migration.version.to_s, + name: migration.name, + database: ActualDbSchema.db_config[:database], + schema: schema_name, + branch: branch, + manual_mode: manual_mode + ) + end + def extract_class_name(filename) content = File.read(filename) content.match(/^class\s+([A-Za-z0-9_]+)\s+