after_migrate 0.2.1 → 0.2.3

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: 19aeb8ce5d84227e4ea467f7e0d14ff8ccdb34f49214f9201b7550ede7ffbbf3
4
- data.tar.gz: 2c52a7d680cb37bc2536be8ccd822fee3b92fc6e2b91a5587fa7da1826d14993
3
+ metadata.gz: b9d807edc34ee3c61bcda0ef4fd2319f80bac36c3bcd1cd1e2a46eb9f5e44f28
4
+ data.tar.gz: 16547159bf93c5a192a38a1e92d441bcd9690412b8d58ae163f59f8bc2da71bb
5
5
  SHA512:
6
- metadata.gz: f8f671d511d7dc8cecc9a33e8bd90752cd0bb5b5ccb50a485dd7215c50ee68d76ec9ce626850d18717bb507a50e0bfe31e71425d24975607144b3749da1d9345
7
- data.tar.gz: 80e42d103e33f52644366ebd660a0d5252c55934386830c68f33c9418b37ffbb92310c1c2dc2dd033d9a62204f0bd8d8c78f222852796d5838c4526ae313809e
6
+ metadata.gz: a7e76304080055fe4b9669d9895b9fb3709266c89e29deb2e39b3d408a29ba88ef1b99ebe951e693da446d2c97fda40ba416b71fae517234b65975e250b6635d
7
+ data.tar.gz: 14823ec925953300f85d28586a232111f945c2b9b8fb20bdaa4bf9d3057f8467dec2eb76defda50317a5811bbd6f7071bf71bb195f188950ab2a340030e0b2a5
data/.gitignore CHANGED
@@ -11,3 +11,4 @@
11
11
  .rspec_status
12
12
  .idea/
13
13
  /Gemfile.lock
14
+ .private/
data/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.2.3] - 2026-06-01
6
+
7
+ ### Added
8
+ - Redis-backed store via `config.store = :redis` for sharing collected migration tables across processes
9
+ - `config.redis` for passing a Redis client, connection pool, or callable client provider
10
+ - `config.redis_key_prefix` and `config.redis_ttl` for namespacing and expiring Redis store keys
11
+ - Redis store support for run isolation via `config.run_id`
12
+ - Redis store specs for persistence, merging, reset behavior, connection pools, `Redis.new` fallback, and missing-client errors
13
+
14
+ ### Changed
15
+ - Store cache keys now include Redis-specific configuration, so changing Redis store settings rebuilds the active store instance
16
+
17
+ ## [0.2.2] - 2026-05-31
18
+
19
+ ### Added
20
+ - Configurable store backend via `config.store`, with `:memory` as the default and `:file` for persistence across separate migration task processes
21
+ - `config.store_path` to control where the file-backed store writes collected table names
22
+ - `config.run_id` to isolate persisted file-store data between independent migration runs
23
+ - File-store locking and atomic writes to avoid corrupting persisted table data during concurrent access
24
+ - Specs for file-store persistence, corrupt JSON handling, adapter dispatch, unsupported adapters, and deferred rake task behavior
25
+
26
+ ### Changed
27
+ - `AfterMigrate.affected_tables`, `AfterMigrate.merge_tables`, and `AfterMigrate.reset!` now delegate through the configured store backend
28
+ - Executor resets the store when maintenance is disabled or no tables are pending, but keeps collected tables when adapter optimization raises so a later run can retry
29
+
30
+ ## [0.2.1] - 2026-05-29
31
+
32
+ ### Changed
33
+ - Refactored SQL identifier matching used by the parser without changing supported table-detection behavior
34
+ - Split executor migration logging message construction into a local variable for clearer formatting and maintenance
35
+ - Added RuboCop project configuration for documentation, method length, and spec block length rules
36
+
5
37
  ## [0.2.0] - 2026-04-28
6
38
 
7
39
  ### Added
data/CLAUDE.md CHANGED
@@ -22,7 +22,7 @@ This is a Rails gem that automatically runs database maintenance (`ANALYZE`, `VA
22
22
 
23
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
24
 
25
- 3. **`AfterMigrate.affected_tables`** (module-level store in `lib/after_migrate.rb`) — a `Concurrent::Map` that persists across multiple rake task invocations. Only cleared when `AfterMigrate.run!` (or `AfterMigrate.reset!`) is called. This replaces the old `Current` (`ActiveSupport::CurrentAttributes`) which was reset after every migration.
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.
26
26
 
27
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
28
 
data/Gemfile CHANGED
@@ -11,3 +11,4 @@ gem 'bundler', '~> 4'
11
11
  gem 'rake', '~> 13.0'
12
12
  gem 'rspec', '~> 3.0'
13
13
  gem 'rubocop', '~> 1.81'
14
+ gem 'simplecov', group: 'test'
@@ -12,24 +12,33 @@ module AfterMigrate
12
12
  module_function
13
13
 
14
14
  def call(schema: nil)
15
- return unless AfterMigrate.configuration.vacuum || AfterMigrate.configuration.analyze != 'none'
15
+ return reset_store unless maintenance_enabled?
16
16
 
17
17
  tables = target_tables
18
- return if tables.blank?
18
+ return reset_store if tables.blank?
19
19
 
20
- if schema.present?
21
- run_optimize(schema:, tables: tables[schema]) if tables[schema].present?
22
- else
23
- tables.each { |s, t| run_optimize(schema: s, tables: t) }
24
- end
25
- ensure
26
- AfterMigrate.reset!
20
+ run_optimizations(schema:, tables:)
21
+ reset_store
27
22
  end
28
23
 
29
24
  public :call
30
25
 
31
26
  private
32
27
 
28
+ def maintenance_enabled?
29
+ AfterMigrate.configuration.vacuum || AfterMigrate.configuration.analyze != 'none'
30
+ end
31
+
32
+ def reset_store
33
+ AfterMigrate.reset!
34
+ end
35
+
36
+ def run_optimizations(schema:, tables:)
37
+ return run_optimize(schema:, tables: tables[schema]) if schema.present? && tables[schema].present?
38
+
39
+ tables.each { |s, t| run_optimize(schema: s, tables: t) } unless schema.present?
40
+ end
41
+
33
42
  def run_optimize(schema:, tables:)
34
43
  table_names = tables.to_a.sort
35
44
  return if table_names.empty?
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+
6
+ module AfterMigrate
7
+ module Stores
8
+ class Memory
9
+ attr_reader :affected_tables
10
+
11
+ def initialize
12
+ @affected_tables = Concurrent::Map.new
13
+ end
14
+
15
+ def merge_tables(schema, table_names)
16
+ return if table_names.blank?
17
+
18
+ set = affected_tables.compute_if_absent(schema) { Concurrent::Set.new }
19
+ set.merge(table_names)
20
+ end
21
+
22
+ def reset!
23
+ @affected_tables = Concurrent::Map.new
24
+ end
25
+ end
26
+
27
+ class FileStore < Memory
28
+ attr_reader :path, :run_id
29
+
30
+ def initialize(path:, run_id: nil)
31
+ super()
32
+ @path = path.to_s
33
+ @run_id = run_id.to_s.presence
34
+ end
35
+
36
+ def affected_tables
37
+ load_into_memory
38
+ super
39
+ end
40
+
41
+ def merge_tables(schema, table_names)
42
+ return if table_names.blank?
43
+
44
+ with_lock do
45
+ merge_into_memory(read_schemas)
46
+ merge_into_memory(schema => table_names)
47
+ write_schemas(memory_to_hash)
48
+ end
49
+ end
50
+
51
+ def reset!
52
+ super
53
+ with_lock { ::FileUtils.rm_f(path) }
54
+ end
55
+
56
+ private
57
+
58
+ def load_into_memory
59
+ with_lock { merge_into_memory(read_schemas) }
60
+ end
61
+
62
+ def merge_into_memory(schemas)
63
+ schemas.each do |schema, table_names|
64
+ set = @affected_tables.compute_if_absent(schema) { Concurrent::Set.new }
65
+ set.merge(table_names)
66
+ end
67
+ end
68
+
69
+ def read_schemas
70
+ return {} unless ::File.exist?(path)
71
+
72
+ payload = JSON.parse(::File.read(path))
73
+ return {} if run_id && payload['run_id'].to_s != run_id
74
+
75
+ payload.fetch('schemas', {})
76
+ rescue JSON::ParserError
77
+ {}
78
+ end
79
+
80
+ def write_schemas(schemas)
81
+ ::FileUtils.mkdir_p(::File.dirname(path))
82
+ temp_path = "#{path}.#{$PROCESS_ID}.tmp"
83
+ ::File.write(temp_path, JSON.pretty_generate({ run_id:, schemas: }))
84
+ ::File.rename(temp_path, path)
85
+ ensure
86
+ ::FileUtils.rm_f(temp_path) if temp_path && ::File.exist?(temp_path)
87
+ end
88
+
89
+ def memory_to_hash
90
+ @affected_tables.keys.sort.each_with_object({}) do |schema, hash|
91
+ hash[schema] = @affected_tables[schema].to_a.sort
92
+ end.sort.to_h
93
+ end
94
+
95
+ def with_lock
96
+ ::FileUtils.mkdir_p(::File.dirname(path))
97
+ ::File.open("#{path}.lock", ::File::RDWR | ::File::CREAT, 0o644) do |file|
98
+ file.flock(::File::LOCK_EX)
99
+ yield
100
+ end
101
+ end
102
+ end
103
+
104
+ class RedisStore < Memory
105
+ attr_reader :key_prefix, :run_id, :ttl
106
+
107
+ def initialize(redis:, key_prefix: 'after_migrate', run_id: nil, ttl: 24 * 60 * 60)
108
+ super()
109
+ @redis = redis
110
+ @key_prefix = key_prefix.to_s
111
+ @run_id = run_id.to_s.presence || 'default'
112
+ @ttl = ttl.to_i
113
+ end
114
+
115
+ def affected_tables
116
+ load_into_memory
117
+ super
118
+ end
119
+
120
+ def merge_tables(schema, table_names)
121
+ return if table_names.blank?
122
+
123
+ with_redis do |redis|
124
+ redis.sadd(index_key, schema)
125
+ redis.sadd(schema_key(schema), table_names.to_a)
126
+ expire_keys(redis, schema)
127
+ end
128
+ merge_into_memory(schema => table_names)
129
+ end
130
+
131
+ def reset!
132
+ super
133
+ with_redis do |redis|
134
+ schemas = redis.smembers(index_key)
135
+ keys = schemas.map { |schema| schema_key(schema) }
136
+ redis.del(*(keys + [index_key])) if keys.any?
137
+ redis.del(index_key) if keys.empty?
138
+ end
139
+ end
140
+
141
+ private
142
+
143
+ def load_into_memory
144
+ with_redis do |redis|
145
+ redis.smembers(index_key).each do |schema|
146
+ merge_into_memory(schema => redis.smembers(schema_key(schema)))
147
+ end
148
+ end
149
+ end
150
+
151
+ def merge_into_memory(schemas)
152
+ schemas.each do |schema, table_names|
153
+ set = @affected_tables.compute_if_absent(schema) { Concurrent::Set.new }
154
+ set.merge(table_names)
155
+ end
156
+ end
157
+
158
+ def index_key
159
+ "#{base_key}:schemas"
160
+ end
161
+
162
+ def schema_key(schema)
163
+ "#{base_key}:schema:#{schema}"
164
+ end
165
+
166
+ def base_key
167
+ "#{key_prefix}:#{run_id}"
168
+ end
169
+
170
+ def expire_keys(redis, schema)
171
+ return unless ttl.positive?
172
+
173
+ redis.expire(index_key, ttl)
174
+ redis.expire(schema_key(schema), ttl)
175
+ end
176
+
177
+ def with_redis(&block)
178
+ redis = resolved_redis
179
+ return redis.with { |connection| block.call(connection) } if redis.respond_to?(:with)
180
+
181
+ block.call(redis)
182
+ end
183
+
184
+ def resolved_redis
185
+ client = @redis.respond_to?(:call) ? @redis.call : @redis
186
+ return client if client
187
+ return Redis.new if defined?(Redis)
188
+
189
+ raise 'AfterMigrate Redis store requires config.redis or the redis gem'
190
+ end
191
+ end
192
+ end
193
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AfterMigrate
4
- VERSION = '0.2.1'
4
+ VERSION = '0.2.3'
5
5
  end
data/lib/after_migrate.rb CHANGED
@@ -1,13 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'after_migrate/version'
4
+ require 'after_migrate/store'
4
5
  require 'after_migrate/collector'
5
6
  require 'after_migrate/executor'
6
7
  require 'after_migrate/railtie'
7
8
 
8
9
  module AfterMigrate
9
10
  class Configuration
10
- attr_accessor :enabled, :verbose, :vacuum, :analyze, :rake_tasks_enhanced, :defer
11
+ attr_accessor :enabled, :verbose, :vacuum, :analyze, :rake_tasks_enhanced, :defer,
12
+ :store, :store_path, :run_id, :redis, :redis_key_prefix, :redis_ttl
11
13
 
12
14
  def initialize
13
15
  @enabled = true
@@ -16,6 +18,12 @@ module AfterMigrate
16
18
  @analyze = 'only_affected_tables'
17
19
  @rake_tasks_enhanced = true
18
20
  @defer = true
21
+ @store = :memory
22
+ @store_path = 'tmp/after_migrate/affected_tables.json'
23
+ @run_id = nil
24
+ @redis = nil
25
+ @redis_key_prefix = 'after_migrate'
26
+ @redis_ttl = 24 * 60 * 60
19
27
  end
20
28
  end
21
29
 
@@ -34,14 +42,11 @@ module AfterMigrate
34
42
 
35
43
  # Persistent cross-migration store: schema_name => Concurrent::Set<table_name>
36
44
  def affected_tables
37
- @affected_tables ||= Concurrent::Map.new
45
+ store.affected_tables
38
46
  end
39
47
 
40
48
  def merge_tables(schema, table_names)
41
- return if table_names.blank?
42
-
43
- set = affected_tables.compute_if_absent(schema) { Concurrent::Set.new }
44
- set.merge(table_names)
49
+ store.merge_tables(schema, table_names)
45
50
  end
46
51
 
47
52
  # Trigger database maintenance on all collected tables, then clear the store.
@@ -53,7 +58,45 @@ module AfterMigrate
53
58
  end
54
59
 
55
60
  def reset!
56
- @affected_tables = nil
61
+ store.reset!
62
+ end
63
+
64
+ def store
65
+ @store = nil if @store_key != store_key
66
+ @store_key = store_key
67
+ @store ||= build_store
68
+ end
69
+
70
+ private
71
+
72
+ def store_key
73
+ [
74
+ configuration.store.to_s,
75
+ configuration.store_path.to_s,
76
+ configuration.run_id.to_s,
77
+ configuration.redis_key_prefix.to_s,
78
+ configuration.redis_ttl.to_s
79
+ ]
80
+ end
81
+
82
+ def build_store
83
+ case configuration.store.to_s
84
+ when 'file'
85
+ Stores::FileStore.new(path: configuration.store_path, run_id: configuration.run_id)
86
+ when 'redis'
87
+ redis_store
88
+ else
89
+ Stores::Memory.new
90
+ end
91
+ end
92
+
93
+ def redis_store
94
+ Stores::RedisStore.new(
95
+ redis: configuration.redis,
96
+ key_prefix: configuration.redis_key_prefix,
97
+ run_id: configuration.run_id,
98
+ ttl: configuration.redis_ttl
99
+ )
57
100
  end
58
101
  end
59
102
  end
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.1
4
+ version: 0.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nikolay Moskvin
@@ -69,6 +69,7 @@ files:
69
69
  - lib/after_migrate/collector.rb
70
70
  - lib/after_migrate/executor.rb
71
71
  - lib/after_migrate/railtie.rb
72
+ - lib/after_migrate/store.rb
72
73
  - lib/after_migrate/version.rb
73
74
  - logo.png
74
75
  homepage: https://github.com/moskvin/after_migrate