Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,6 @@ Metrics/CyclomaticComplexity:

Metrics/PerceivedComplexity:
Max: 10

Metrics/AbcSize:
Enabled: false
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions lib/actual_db_schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 5 additions & 4 deletions lib/actual_db_schema/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions lib/actual_db_schema/instrumentation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

module ActualDbSchema
module Instrumentation
ROLLBACK_EVENT = "rollback.actual_db_schema"
end
end
24 changes: 21 additions & 3 deletions lib/actual_db_schema/patches/migration_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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+</)[1]
Expand Down
102 changes: 102 additions & 0 deletions lib/actual_db_schema/rollback_stats_repository.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# frozen_string_literal: true

module ActualDbSchema
# Persists rollback events in DB.
class RollbackStatsRepository
TABLE_NAME = "actual_db_schema_rollback_events"

class << self
def record(payload)
ensure_table!
connection.execute(<<~SQL.squish)
INSERT INTO #{quoted_table}
(#{quoted_column("version")}, #{quoted_column("name")}, #{quoted_column("database")},
#{quoted_column("schema")}, #{quoted_column("branch")}, #{quoted_column("manual_mode")},
#{quoted_column("created_at")})
VALUES
(#{connection.quote(payload[:version].to_s)}, #{connection.quote(payload[:name].to_s)},
#{connection.quote(payload[:database].to_s)}, #{connection.quote((payload[:schema] || "default").to_s)},
#{connection.quote(payload[:branch].to_s)}, #{connection.quote(!!payload[:manual_mode])},
#{connection.quote(Time.current)})
SQL
end

def stats
return empty_stats unless table_exists?

{
total: total_rollbacks,
by_database: aggregate_by(:database),
by_schema: aggregate_by(:schema),
by_branch: aggregate_by(:branch)
}
end

def total_rollbacks
return 0 unless table_exists?

connection.select_value(<<~SQL.squish).to_i
SELECT COUNT(*) FROM #{quoted_table}
SQL
end

def reset!
return unless table_exists?

connection.execute("DELETE FROM #{quoted_table}")
end

private

def ensure_table!
return if table_exists?

connection.create_table(TABLE_NAME) do |t|
t.string :version, null: false
t.string :name
t.string :database, null: false
t.string :schema
t.string :branch, null: false
t.boolean :manual_mode, null: false, default: false
t.datetime :created_at, null: false
end
end

def table_exists?
connection.table_exists?(TABLE_NAME)
end

def aggregate_by(column)
return {} unless table_exists?

rows = connection.select_all(<<~SQL.squish)
SELECT #{quoted_column(column)}, COUNT(*) AS cnt
FROM #{quoted_table}
GROUP BY #{quoted_column(column)}
SQL
rows.each_with_object(Hash.new(0)) { |row, h| h[row[column.to_s].to_s] = row["cnt"].to_i }
end

def empty_stats
{
total: 0,
by_database: {},
by_schema: {},
by_branch: {}
}
end

def connection
ActiveRecord::Base.connection
end

def quoted_table
connection.quote_table_name(TABLE_NAME)
end

def quoted_column(name)
connection.quote_column_name(name)
end
end
end
end
9 changes: 9 additions & 0 deletions lib/generators/actual_db_schema/templates/actual_db_schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,13 @@
# config.migrations_storage = :db
config.migrations_storage = :file
end

# Subscribe to rollback events to persist stats (optional).
# Uncomment the following to track rollback statistics in the database:
#
# ActiveSupport::Notifications.subscribe(
# ActualDbSchema::Instrumentation::ROLLBACK_EVENT
# ) do |_name, _start, _finish, _id, payload|
# ActualDbSchema::RollbackStatsRepository.record(payload)
# end
end
39 changes: 39 additions & 0 deletions test/rake_task_db_storage_full_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@
end

describe "db:rollback_branches" do
def collect_rollback_events
events = []
subscriber = ActiveSupport::Notifications.subscribe(ActualDbSchema::Instrumentation::ROLLBACK_EVENT) do |*args|
events << ActiveSupport::Notifications::Event.new(*args)
end

yield events
ensure
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
end

it "creates the tmp/migrated folder" do
refute File.exist?(utils.app_file("tmp/migrated"))
utils.run_migrations
Expand All @@ -42,6 +53,22 @@
assert_empty utils.migrated_files
end

it "emits one instrumentation event per successful rollback" do
utils.prepare_phantom_migrations
events = nil

collect_rollback_events do |captured_events|
utils.run_migrations
events = captured_events
end

assert_equal 2, events.size
assert_equal(%w[20130906111512 20130906111511], events.map { |event| event.payload[:version] })
assert_equal([false, false], events.map { |event| event.payload[:manual_mode] })
assert_equal([utils.primary_database, utils.primary_database], events.map { |event| event.payload[:database] })
assert_equal([nil, nil], events.map { |event| event.payload[:schema] })
end

describe "with irreversible migration" do
before do
utils.define_migration_file("20130906111513_irreversible.rb", <<~RUBY)
Expand All @@ -67,6 +94,18 @@ def down
assert_match(/ActiveRecord::IrreversibleMigration/, TestingState.output)
assert_equal %w[20130906111513_irreversible.rb], utils.migrated_files
end

it "does not emit instrumentation for failed rollbacks" do
utils.prepare_phantom_migrations
events = nil

collect_rollback_events do |captured_events|
utils.run_migrations
events = captured_events
end

assert_equal(%w[20130906111512 20130906111511], events.map { |event| event.payload[:version] })
end
end

describe "with irreversible migration is the first" do
Expand Down
39 changes: 39 additions & 0 deletions test/rake_task_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@
end

describe "db:rollback_branches" do
def collect_rollback_events
events = []
subscriber = ActiveSupport::Notifications.subscribe(ActualDbSchema::Instrumentation::ROLLBACK_EVENT) do |*args|
events << ActiveSupport::Notifications::Event.new(*args)
end

yield events
ensure
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
end

it "creates the tmp/migrated folder" do
refute File.exist?(utils.app_file("tmp/migrated"))
utils.run_migrations
Expand All @@ -40,6 +51,22 @@
assert_empty utils.migrated_files
end

it "emits one instrumentation event per successful rollback" do
utils.prepare_phantom_migrations
events = nil

collect_rollback_events do |captured_events|
utils.run_migrations
events = captured_events
end

assert_equal 2, events.size
assert_equal(%w[20130906111512 20130906111511], events.map { |event| event.payload[:version] })
assert_equal([false, false], events.map { |event| event.payload[:manual_mode] })
assert_equal([utils.primary_database, utils.primary_database], events.map { |event| event.payload[:database] })
assert_equal([nil, nil], events.map { |event| event.payload[:schema] })
end

describe "with irreversible migration" do
before do
utils.define_migration_file("20130906111513_irreversible.rb", <<~RUBY)
Expand All @@ -65,6 +92,18 @@ def down
assert_match(/ActiveRecord::IrreversibleMigration/, TestingState.output)
assert_equal %w[20130906111513_irreversible.rb], utils.migrated_files
end

it "does not emit instrumentation for failed rollbacks" do
utils.prepare_phantom_migrations
events = nil

collect_rollback_events do |captured_events|
utils.run_migrations
events = captured_events
end

assert_equal(%w[20130906111512 20130906111511], events.map { |event| event.payload[:version] })
end
end

describe "with irreversible migration is the first" do
Expand Down