after_migrate 0.2.6 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8c9c1befe1200c2536f27cf80f99dc05d1b825745c1d01c13499dae96275b61f
4
- data.tar.gz: 8e8cc6b87918d25303c668c1cbe2fbfd4129c937bac9b83284a7ea639fb42453
3
+ metadata.gz: 453b86e338e7eb2ba4eb0af92cde363cee1de07ade6bcaaec31f953cc6938e32
4
+ data.tar.gz: f2673ef800205a658f30d54865724e34b77b73a5fcd6df7e7b608af1d5df3fb7
5
5
  SHA512:
6
- metadata.gz: 42015b8f9c703839c91318c2bc165ff924f3e0b65dd4a0e6e770721456be25f560ea5674f8b50638130fb9bcce4ea6033aeddad7f4893bf4a9db0fe70a03e1c1
7
- data.tar.gz: bae706e4aaaa059698cc9e9fc45c1e4f05c32d0227b1408ba374359d44d3b1d7ab69e4ae9e450747032d0d27381bc394d8659772593525d45d06ef0e5a409ab4
6
+ metadata.gz: 219bcd2a38ec717a6837024b6c3d969d0e6ec04d23f153ee2ff1eb1a096bcd221b7ec396f428db4906a65afa317ad17abc13f2a3320de5ae1cd64d6c058d09e1
7
+ data.tar.gz: 290f5f231817b412be23ebe12235ed430fbf7f660e7be20bdb7c8aa5f2cc731bae4a55ffeaae9888fbc3ccfa4722dbf6c9aabe595f1305d8a5d01a7ca31f0a5f
data/.gitignore CHANGED
@@ -12,3 +12,4 @@
12
12
  .idea/
13
13
  /Gemfile.lock
14
14
  .private/
15
+ .DS_Store
data/AGENTS.md ADDED
@@ -0,0 +1,74 @@
1
+ # AGENTS.md
2
+
3
+ This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
4
+
5
+ ## Commands
6
+
7
+ ```bash
8
+ bundle install # install dependencies
9
+ bundle exec rspec # run all tests
10
+ bundle exec rspec spec/after_migrate_spec.rb # run a single spec file
11
+ bundle exec rake # default task (runs spec)
12
+ bundle exec rubocop # lint
13
+ bundle exec rubocop -a # lint with auto-fix
14
+ rake release # build and push gem to RubyGems
15
+ ```
16
+
17
+ ## Architecture
18
+
19
+ This is a Rails gem that automatically runs database maintenance (`ANALYZE`, `VACUUM`, `PRAGMA optimize`) after `db:migrate` tasks. The core flow is:
20
+
21
+ 1. **`Railtie`** (`lib/after_migrate/railtie.rb`) — the entry point. On Rails init, it subscribes to `sql.active_record` notifications so every SQL statement during a migration is intercepted. It enhances `db:migrate`, `db:migrate:up`, and `db:migrate:redo` to call `AfterMigrate.run!` when they complete — unless `defer: true` (the default), in which case the rake task only collects. It also registers the `after_migrate:run` task.
22
+
23
+ 2. **`Collector`** (`lib/after_migrate/collector.rb`) — receives every SQL notification, filters to DDL/DML statements (CREATE/ALTER/DROP/INSERT/UPDATE/DELETE), calls the adapter-specific parser, and accumulates table names into `AfterMigrate.affected_tables` (a `Concurrent::Map<schema, Concurrent::Set<table_name>>`).
24
+
25
+ 3. **`AfterMigrate.store`** (`lib/after_migrate/store.rb`) — defaults to an in-memory `Concurrent::Map` wrapped by `AfterMigrate::Stores::Memory`. `AfterMigrate.affected_tables`, `merge_tables`, and `reset!` delegate to the store. The memory store persists across multiple rake task invocations inside one Ruby process and is cleared by `AfterMigrate.run!` (or `AfterMigrate.reset!`). This replaces the old `Current` (`ActiveSupport::CurrentAttributes`) which was reset after every migration. Alternate backends: `FileStore` (JSON, cross-invocation) and `RedisStore` (cross-process, for parallel/multi-tenant runs). `RedisStore#resolved_redis` memoizes the resolved client per store instance, so `config.redis` is only materialized once — pass a `ConnectionPool` (the store calls `pool.with`) or a memoized client, never a `-> { Redis.new }` proc that builds a connection per call (that opens one connection per SQL statement and exhausts the Redis connection limit under concurrency).
26
+
27
+ 4. **`Executor`** (`lib/after_migrate/executor.rb`) — iterates `AfterMigrate.affected_tables` and calls the correct adapter's `optimize_tables`. Respects the `analyze` config option (`only_affected_tables` / `all_tables` / `none`). Always calls `AfterMigrate.reset!` in its `ensure` block.
28
+
29
+ 5. **Adapters** (`lib/after_migrate/adapters/`) — one module per database:
30
+ - `Sql` — shared regex-based table parser (used by MySQL and SQLite, which can't use pg_query)
31
+ - `Postgresql` — uses `pg_query` gem for accurate SQL parsing; runs `VACUUM` (checking `pg_stat_all_tables` for dead tuples) then `ANALYZE VERBOSE` per table
32
+ - `Mysql` — runs `ANALYZE TABLE` per table; lists tables from `information_schema`
33
+ - `Sqlite` — runs `PRAGMA optimize` (SQLite ≥ 3.35.0) or `VACUUM; ANALYZE;`
34
+
35
+ ## Key design decisions
36
+
37
+ - PostgreSQL uses `pg_query` (the actual Postgres parser) for table extraction, which avoids false positives from regex. MySQL and SQLite fall back to the shared `Sql` regex patterns.
38
+ - `pg_query` is a hard runtime dependency (listed in gemspec), not optional — even though it's only used for PostgreSQL.
39
+ - Table collection uses `Concurrent::Map` + `Concurrent::Set` (from `concurrent-ruby`) for thread-safe accumulation across parallel migration workers.
40
+ - The gem does **not** monkey-patch ActiveRecord. It only uses public `ActiveSupport::Notifications` and `Rake::Task#enhance` APIs.
41
+ - `defer: true` (the default) is the multi-tenant-friendly mode: rake tasks only collect, never execute. Call `AfterMigrate.run!` (or `rake after_migrate:run`) once after all tenant migrations complete.
42
+ - The `app.executor.to_run` unsubscription in `Railtie` ensures the SQL subscription is dropped before the app starts serving web requests, so normal traffic is never collected.
43
+
44
+ ## Configuration
45
+
46
+ Config values are in `AfterMigrate::Configuration` (initialized in `lib/after_migrate.rb`):
47
+
48
+ | Option | Default | Values |
49
+ |-----------------------|--------------------------|---------------------------------------------------------|
50
+ | `enabled` | `true` | bool |
51
+ | `verbose` | `true` | bool |
52
+ | `vacuum` | `true` | bool (PostgreSQL only) |
53
+ | `analyze` | `"only_affected_tables"` | `"only_affected_tables"`, `"all_tables"`, `"none"` |
54
+ | `rake_tasks_enhanced` | `true` | bool |
55
+ | `defer` | `true` | bool — skip auto-run; call `AfterMigrate.run!` manually |
56
+
57
+ ## Multi-tenant usage
58
+
59
+ ```ruby
60
+ # In your tenant migration runner:
61
+ Tenant.each do |tenant|
62
+ tenant.switch { ActiveRecord::MigrationContext.new(...).migrate }
63
+ end
64
+ # Tables are now accumulated per schema in AfterMigrate.affected_tables
65
+ AfterMigrate.run! # or: Rake::Task['after_migrate:run'].invoke
66
+ ```
67
+
68
+ Set `defer: false` to restore the v0.1 behaviour of running after each `db:migrate`.
69
+
70
+ ## Dependencies
71
+
72
+ - Ruby ≥ 3.2, Rails ≥ 7.0
73
+ - `pg_query ≥ 6.1` (required even for non-Postgres installs)
74
+ - Dev: `rspec ~> 3.0`, `rubocop ~> 1.81`, `bundler ~> 4`
data/CHANGELOG.md CHANGED
@@ -2,6 +2,57 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.3.0] - 2026-08-07
6
+
7
+ ### Fixed
8
+ - **Worker/web processes could hang indefinitely.** The railtie registered
9
+ `app.executor.to_run { ActiveSupport::Notifications.unsubscribe(subscription) }`, which fires on
10
+ *every* executor invocation - every web request and, through `Sidekiq::Rails::Reloader`, every
11
+ background job. Each call takes the process-global `ActiveSupport::Notifications::Fanout` mutex and
12
+ clears the listener cache, forcing every subsequent instrumented statement to contend on the same
13
+ mutex via `Fanout#all_listeners_for`. Under concurrency this collapsed: a production Sidekiq
14
+ process lost all five worker threads for 7+ hours, each parked on `fanout.rb:82`
15
+ `Thread::Mutex#synchronize` inside this callback. The unsubscribe is now one-shot, so every call
16
+ after the first is a boolean check.
17
+
18
+ - **Store failures no longer abort migrations.** `Collector` runs inside an `sql.active_record`
19
+ subscriber, and ActiveSupport propagates subscriber exceptions to whoever emitted the event -- so a
20
+ Redis error aborted the SQL statement and with it the migration. Running 25 parallel
21
+ `rails apartment:migrate` workers against one Redis produced
22
+ `Connection refused - connect(2) for <host>:6379` and killed migrations mid-run. `merge_tables`,
23
+ `affected_tables` and `reset!` now degrade to a warning: the first failure is logged in full,
24
+ subsequent ones are counted (`AfterMigrate.store_failure_count`), and `AfterMigrate.store_degraded?`
25
+ reports that collected tables are incomplete. Losing table names only costs some ANALYZE coverage;
26
+ a half-applied migration costs far more.
27
+
28
+ ### Added
29
+ - `config.raise_on_store_error` (default `false`) to restore the old fatal behaviour.
30
+ - `AfterMigrate.store_degraded?` and `AfterMigrate.store_failure_count`.
31
+
32
+ ### Changed
33
+ - **BREAKING: `enabled` is now auto-detected**, resolved at `Configuration` construction time:
34
+
35
+ | `AFTER_MIGRATE_ENABLED` | result |
36
+ |-------------------------|-----------------------------------------------|
37
+ | `true` | always on |
38
+ | `false` | always off - an explicit opt-out always wins |
39
+ | unset / blank | **on only while a migration task is running** |
40
+
41
+ Auto-detection matches the running task against `db:migrate`, `db:migrate:up`, `db:rollback`,
42
+ `apartment:migrate`, `db:schema:load`, `db:structure:load` and `after_migrate:*`, reading
43
+ `Rake.application.top_level_tasks` with `ARGV` as a fallback. It deliberately does not match
44
+ `db:seed`, `assets:precompile`, `server`, `console`, or a Sidekiq command line -- so a web or
45
+ worker process never arms the gem, while `rails db:migrate` needs no configuration at all.
46
+
47
+ Previously, there was no way to keep the gem out of long-running processes.
48
+
49
+ This gem is a migration-time tool, and there was previously no way to keep it out of long-running
50
+ processes: railtie initializers run *before* `:load_config_initializers`
51
+ (`Rails::Application#ordered_railties` pushes the application last), so a host app calling
52
+ `AfterMigrate.configure { |c| c.enabled = false }` in `config/initializers/` was always too late --
53
+ the railtie had already subscribed and registered the executor hook. Reading the environment
54
+ variable directly is the only way the railtie can see the intended value.
55
+
5
56
  ## [0.2.6] - 2026-07-16
6
57
 
7
58
  ### Fixed
data/README.md CHANGED
@@ -59,10 +59,22 @@ Out of the box, it runs `ANALYZE` on **only the tables touched** during the migr
59
59
 
60
60
  Create `config/initializers/after_migrate.rb`:
61
61
 
62
+ > **`enabled` is auto-detected -- do not set it here.**
63
+ >
64
+ > | `AFTER_MIGRATE_ENABLED` | result |
65
+ > |-------------------------|-----------------------------------------------------------------------------------------------------------------|
66
+ > | `true` | always on |
67
+ > | `false` | always off (explicit opt-out wins) |
68
+ > | unset | **on only while running `db:migrate`, `db:rollback`, `apartment:migrate`, `db:schema:load`, `after_migrate:*`** |
69
+ >
70
+ > It must be resolved from the environment, because Rails runs railtie initializers *before*
71
+ > `config/initializers/*.rb` -- by the time this block executes, the gem has already decided whether
72
+ > to subscribe. Assigning `config.enabled` here still affects `AfterMigrate.run!`, but **cannot**
73
+ > switch collection on or off, and setting it to a fixed value will override auto-detection for
74
+ > `run!`. Leave it alone unless you mean it.
75
+
62
76
  ```ruby
63
77
  AfterMigrate.configure do |config|
64
- # Enable/disable the gem
65
- config.enabled = true
66
78
 
67
79
  # Log what’s happening
68
80
  config.verbose = true
@@ -12,9 +12,27 @@ module AfterMigrate
12
12
  AfterMigrate::Collector.call(*args)
13
13
  end
14
14
 
15
- # Unsubscribe when the app starts serving requests so normal web traffic
16
- # is never collected.
17
- app.executor.to_run { ActiveSupport::Notifications.unsubscribe(subscription) }
15
+ # Unsubscribe once, the first time the app starts doing work, so normal web/worker traffic is
16
+ # never collected.
17
+ #
18
+ # THIS MUST STAY ONE-SHOT. `executor.to_run` fires on EVERY executor invocation -- every web
19
+ # request and, via Sidekiq::Rails::Reloader, every single background job. Calling
20
+ # `Notifications.unsubscribe` there takes the process-global
21
+ # `ActiveSupport::Notifications::Fanout` mutex and calls `clear_cache`, which invalidates the
22
+ # listener cache so every subsequent instrumented statement must take the same mutex through
23
+ # `Fanout#all_listeners_for`.
24
+ done = false
25
+ lock = Mutex.new
26
+ app.executor.to_run do
27
+ next if done
28
+
29
+ lock.synchronize do
30
+ next if done
31
+
32
+ done = true
33
+ ActiveSupport::Notifications.unsubscribe(subscription)
34
+ end
35
+ end
18
36
  end
19
37
 
20
38
  rake_tasks do
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AfterMigrate
4
- VERSION = '0.2.6'
4
+ VERSION = '0.3.0'
5
5
  end
data/lib/after_migrate.rb CHANGED
@@ -9,15 +9,80 @@ require 'after_migrate/railtie'
9
9
  module AfterMigrate
10
10
  class Configuration
11
11
  attr_accessor :enabled, :verbose, :vacuum, :analyze, :rake_tasks_enhanced, :defer,
12
- :store, :run_id, :store_options
12
+ :store, :run_id, :store_options, :raise_on_store_error
13
+
14
+ # Task names that mean "this process is applying schema changes". Matches `db:migrate`,
15
+ # `db:migrate:up`, `db:rollback`, `apartment:migrate`, `db:schema:load`, `after_migrate:*` --
16
+ # and deliberately NOT `db:seed`, `assets:precompile`, `server`, `console`, or a bare Sidekiq
17
+ # command line.
18
+ MIGRATION_TASK_PATTERN = /
19
+ (?:\A|:)(?:migrate|rollback)(?::|\z)
20
+ | \Adb:(?:schema|structure):load\z
21
+ | \Aafter_migrate:
22
+ /x
23
+
24
+ class << self
25
+ # Three states, so the common case needs no configuration at all:
26
+ #
27
+ # AFTER_MIGRATE_ENABLED=true -> always on (force it, e.g. from a console)
28
+ # AFTER_MIGRATE_ENABLED=false -> always off (explicit opt-out always wins)
29
+ # unset -> ON only while running migrations
30
+ #
31
+ # Auto-detection is safe because a web or worker process never has a migration task on its
32
+ # command line, so the gem simply never arms itself there.
33
+ def resolve_enabled
34
+ override = env_override
35
+ return override unless override.nil?
36
+
37
+ migration_task?
38
+ end
39
+
40
+ # nil when unset/blank so "not configured" stays distinguishable from "configured false".
41
+ def env_override
42
+ raw = ENV['AFTER_MIGRATE_ENABLED'].to_s.strip
43
+ return nil if raw.empty?
44
+
45
+ raw.casecmp('true').zero?
46
+ end
47
+
48
+ def migration_task?
49
+ task_names.any? { |name| MIGRATION_TASK_PATTERN.match?(name) }
50
+ end
51
+
52
+ # Rake's parsed task list is the reliable source (`rails db:migrate` dispatches through Rake
53
+ # and the environment is loaded by the `environment` prerequisite, so top_level_tasks is
54
+ # already populated by the time our railtie runs). ARGV is the belt-and-braces fallback for
55
+ # `rake db:migrate` and for anything that bypasses Rake's parser.
56
+ def task_names
57
+ names = []
58
+ if defined?(::Rake) && ::Rake.respond_to?(:application)
59
+ top_level = ::Rake.application.top_level_tasks
60
+ names.concat(Array(top_level))
61
+ end
62
+ names.concat(Array(ARGV))
63
+ names.map(&:to_s)
64
+ rescue StandardError
65
+ Array(ARGV).map(&:to_s)
66
+ end
67
+ end
13
68
 
69
+ # Resolved here, not in a host app's `config/initializers/*.rb`.
70
+ #
71
+ # Railtie initializers run BEFORE `:load_config_initializers` (Rails::Application#ordered_railties
72
+ # pushes the application last), so by the time an app calls `AfterMigrate.configure`, our railtie
73
+ # has already decided whether to subscribe. An app setting `config.enabled = false` therefore
74
+ # could NOT switch the subscription off -- the only value the railtie ever sees is this one.
75
+ #
76
+ # Off outside migrations: leaving this armed in every web and worker process.
14
77
  def initialize
15
- @enabled = true
78
+ @enabled = self.class.resolve_enabled
16
79
  @verbose = true
17
80
  @vacuum = true
18
81
  @analyze = 'only_affected_tables'
19
82
  @rake_tasks_enhanced = true
20
83
  @defer = true
84
+ # Store failures degrade to a warning by default; they must not abort a migration.
85
+ @raise_on_store_error = false
21
86
  @store = :memory
22
87
  @run_id = ENV.fetch('AFTER_MIGRATE_RUN_ID', 'default')
23
88
  @store_options = {
@@ -84,11 +149,21 @@ module AfterMigrate
84
149
 
85
150
  # Persistent cross-migration store: schema_name => Concurrent::Set<table_name>
86
151
  def affected_tables
87
- store.affected_tables
152
+ with_store_rescue('affected_tables', default: Concurrent::Map.new) { store.affected_tables }
88
153
  end
89
154
 
90
155
  def merge_tables(schema, table_names)
91
- store.merge_tables(schema, table_names)
156
+ with_store_rescue('merge_tables') { store.merge_tables(schema, table_names) }
157
+ end
158
+
159
+ # True, when at least one store operation failed and was swallowed, so the collected table list
160
+ # is incomplete and maintenance coverage will be partial.
161
+ def store_degraded?
162
+ @store_degraded == true
163
+ end
164
+
165
+ def store_failure_count
166
+ @store_failure_count.to_i
92
167
  end
93
168
 
94
169
  # Trigger database maintenance on all collected tables, then clear the store.
@@ -100,7 +175,36 @@ module AfterMigrate
100
175
  end
101
176
 
102
177
  def reset!
103
- store.reset!
178
+ with_store_rescue('reset!') { store.reset! }
179
+ @store_degraded = false
180
+ @store_failure_count = 0
181
+ nil
182
+ end
183
+
184
+ # A maintenance tool must never break the thing it is measuring.
185
+ #
186
+ # `Collector` runs inside an `sql.active_record` notification subscriber, and ActiveSupport
187
+ # propagates subscriber exceptions to the code that emitted the event — so a store error here
188
+ # aborts the SQL statement, which aborts the migration.
189
+ #
190
+ # Losing collected table names only means some tables miss their ANALYZE -- recoverable, and far
191
+ # cheaper than a half-applied migration. Set `config.raise_on_store_error = true` to opt out.
192
+ #
193
+ # The first failure logs in full; the rest are counted only, so a long migration cannot flood
194
+ # the log with one line per statement.
195
+ def with_store_rescue(operation, default: nil)
196
+ yield
197
+ rescue StandardError => e
198
+ raise if configuration.raise_on_store_error
199
+
200
+ @store_failure_count = store_failure_count + 1
201
+ unless @store_degraded
202
+ @store_degraded = true
203
+ log("store #{operation} failed (#{e.class}: #{e.message}). Continuing without the store — " \
204
+ 'collected tables will be incomplete and some maintenance may be skipped. ' \
205
+ 'Set config.raise_on_store_error = true to make this fatal.')
206
+ end
207
+ default
104
208
  end
105
209
 
106
210
  def store
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: after_migrate
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.6
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nikolay Moskvin
@@ -52,6 +52,7 @@ files:
52
52
  - ".rubocop.yml"
53
53
  - ".ruby-version"
54
54
  - ".travis.yml"
55
+ - AGENTS.md
55
56
  - CHANGELOG.md
56
57
  - CLAUDE.md
57
58
  - Gemfile