familia 2.0.0.pre26 → 2.1.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 +4 -4
- data/CHANGELOG.rst +94 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +12 -2
- data/README.md +1 -3
- data/docs/guides/feature-encrypted-fields.md +1 -1
- data/docs/guides/feature-expiration.md +1 -1
- data/docs/guides/feature-quantization.md +1 -1
- data/docs/guides/writing-migrations.md +345 -0
- data/docs/overview.md +7 -7
- data/docs/reference/api-technical.md +103 -7
- data/examples/migrations/v1_to_v2_serialization_migration.rb +374 -0
- data/examples/schemas/customer.json +33 -0
- data/examples/schemas/session.json +27 -0
- data/familia.gemspec +3 -2
- data/lib/familia/features/schema_validation.rb +139 -0
- data/lib/familia/migration/base.rb +447 -0
- data/lib/familia/migration/errors.rb +31 -0
- data/lib/familia/migration/model.rb +418 -0
- data/lib/familia/migration/pipeline.rb +226 -0
- data/lib/familia/migration/rake_tasks.rake +3 -0
- data/lib/familia/migration/rake_tasks.rb +160 -0
- data/lib/familia/migration/registry.rb +364 -0
- data/lib/familia/migration/runner.rb +311 -0
- data/lib/familia/migration/script.rb +234 -0
- data/lib/familia/migration.rb +43 -0
- data/lib/familia/schema_registry.rb +173 -0
- data/lib/familia/settings.rb +63 -1
- data/lib/familia/version.rb +1 -1
- data/lib/familia.rb +1 -0
- data/try/features/schema_registry_try.rb +193 -0
- data/try/features/schema_validation_feature_try.rb +218 -0
- data/try/migration/base_try.rb +226 -0
- data/try/migration/errors_try.rb +67 -0
- data/try/migration/integration_try.rb +451 -0
- data/try/migration/model_try.rb +431 -0
- data/try/migration/pipeline_try.rb +460 -0
- data/try/migration/rake_tasks_try.rb +61 -0
- data/try/migration/registry_try.rb +199 -0
- data/try/migration/runner_try.rb +311 -0
- data/try/migration/schema_validation_try.rb +201 -0
- data/try/migration/script_try.rb +192 -0
- data/try/migration/v1_to_v2_serialization_try.rb +513 -0
- data/try/performance/benchmarks_try.rb +11 -12
- metadata +45 -27
- data/docs/migrating/v2.0.0-pre.md +0 -84
- data/docs/migrating/v2.0.0-pre11.md +0 -253
- data/docs/migrating/v2.0.0-pre12.md +0 -306
- data/docs/migrating/v2.0.0-pre13.md +0 -95
- data/docs/migrating/v2.0.0-pre14.md +0 -37
- data/docs/migrating/v2.0.0-pre18.md +0 -58
- data/docs/migrating/v2.0.0-pre19.md +0 -197
- data/docs/migrating/v2.0.0-pre22.md +0 -241
- data/docs/migrating/v2.0.0-pre5.md +0 -131
- data/docs/migrating/v2.0.0-pre6.md +0 -154
- data/docs/migrating/v2.0.0-pre7.md +0 -222
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Familia
|
|
4
|
+
module Migration
|
|
5
|
+
# Runner orchestrates migration execution with dependency resolution.
|
|
6
|
+
#
|
|
7
|
+
# Provides methods for querying migration status, validating dependencies,
|
|
8
|
+
# and executing migrations in the correct order using topological sorting.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage
|
|
11
|
+
# runner = Familia::Migration::Runner.new
|
|
12
|
+
# runner.status # => Array of migration status hashes
|
|
13
|
+
# runner.pending # => Array of unapplied migration classes
|
|
14
|
+
# runner.run # => Execute all pending migrations
|
|
15
|
+
#
|
|
16
|
+
# @example Dry run
|
|
17
|
+
# runner.run(dry_run: true) # Preview without applying changes
|
|
18
|
+
#
|
|
19
|
+
# @example Rolling back
|
|
20
|
+
# runner.rollback('20260131_add_status_field')
|
|
21
|
+
#
|
|
22
|
+
class Runner
|
|
23
|
+
# @return [Array<Class>] Migration classes to operate on
|
|
24
|
+
attr_reader :migrations
|
|
25
|
+
|
|
26
|
+
# @return [Registry] Registry for tracking applied migrations
|
|
27
|
+
attr_reader :registry
|
|
28
|
+
|
|
29
|
+
# @return [Logger] Logger for migration output
|
|
30
|
+
attr_reader :logger
|
|
31
|
+
|
|
32
|
+
# Initialize a new Runner instance.
|
|
33
|
+
#
|
|
34
|
+
# @param migrations [Array<Class>, nil] Migration classes (defaults to registered migrations)
|
|
35
|
+
# @param registry [Registry, nil] Registry instance (defaults to new Registry)
|
|
36
|
+
# @param logger [Logger, nil] Logger instance (defaults to Familia.logger)
|
|
37
|
+
#
|
|
38
|
+
def initialize(migrations: nil, registry: nil, logger: nil)
|
|
39
|
+
@migrations = migrations || Familia::Migration.migrations
|
|
40
|
+
@registry = registry || Registry.new
|
|
41
|
+
@logger = logger || Familia.logger
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# --- Status Methods ---
|
|
45
|
+
|
|
46
|
+
# Get the status of all migrations.
|
|
47
|
+
#
|
|
48
|
+
# @return [Array<Hash>] Array of migration info hashes with keys:
|
|
49
|
+
# - :migration_id [String] The migration identifier
|
|
50
|
+
# - :description [String] Human-readable description
|
|
51
|
+
# - :status [Symbol] :applied or :pending
|
|
52
|
+
# - :applied_at [Time, nil] When the migration was applied
|
|
53
|
+
# - :reversible [Boolean] Whether the migration has a down method
|
|
54
|
+
#
|
|
55
|
+
def status
|
|
56
|
+
# Batch fetch all applied migrations with timestamps in a single Redis call
|
|
57
|
+
applied_info = @registry.all_applied.each_with_object({}) do |entry, hash|
|
|
58
|
+
hash[entry[:migration_id]] = entry[:applied_at]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
@migrations.map do |klass|
|
|
62
|
+
id = klass.migration_id
|
|
63
|
+
applied_at = applied_info[id]
|
|
64
|
+
{
|
|
65
|
+
migration_id: id,
|
|
66
|
+
description: klass.description,
|
|
67
|
+
status: applied_at ? :applied : :pending,
|
|
68
|
+
applied_at: applied_at,
|
|
69
|
+
reversible: klass.new.reversible?,
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Get all pending (unapplied) migrations.
|
|
75
|
+
#
|
|
76
|
+
# @return [Array<Class>] Migration classes that haven't been applied
|
|
77
|
+
#
|
|
78
|
+
def pending
|
|
79
|
+
@registry.pending(@migrations)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Validate migration dependencies and configuration.
|
|
83
|
+
#
|
|
84
|
+
# @return [Array<Hash>] Array of issue hashes with keys:
|
|
85
|
+
# - :type [Symbol] Type of issue (:missing_dependency, :circular_dependency)
|
|
86
|
+
# - :migration_id [String] Migration with the issue (for missing deps)
|
|
87
|
+
# - :dependency [String] Missing dependency ID (for missing deps)
|
|
88
|
+
# - :message [String] Error message (for circular deps)
|
|
89
|
+
#
|
|
90
|
+
def validate
|
|
91
|
+
issues = []
|
|
92
|
+
|
|
93
|
+
# Check for missing dependencies
|
|
94
|
+
all_ids = @migrations.map(&:migration_id)
|
|
95
|
+
@migrations.each do |klass|
|
|
96
|
+
(klass.dependencies || []).each do |dep_id|
|
|
97
|
+
unless all_ids.include?(dep_id)
|
|
98
|
+
issues << {
|
|
99
|
+
type: :missing_dependency,
|
|
100
|
+
migration_id: klass.migration_id,
|
|
101
|
+
dependency: dep_id,
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Check for circular dependencies
|
|
108
|
+
begin
|
|
109
|
+
topological_sort(@migrations)
|
|
110
|
+
rescue Familia::Migration::Errors::CircularDependency => e
|
|
111
|
+
issues << { type: :circular_dependency, message: e.message }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
issues
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# --- Execution Methods ---
|
|
118
|
+
|
|
119
|
+
# Run all pending migrations in dependency order.
|
|
120
|
+
#
|
|
121
|
+
# @param dry_run [Boolean] If true, preview without applying changes
|
|
122
|
+
# @param limit [Integer, nil] Maximum number of migrations to run
|
|
123
|
+
# @return [Array<Hash>] Results for each migration attempted
|
|
124
|
+
#
|
|
125
|
+
def run(dry_run: false, limit: nil)
|
|
126
|
+
pending_migrations = topological_sort(pending)
|
|
127
|
+
pending_migrations = pending_migrations.first(limit) if limit
|
|
128
|
+
|
|
129
|
+
results = []
|
|
130
|
+
pending_migrations.each do |klass|
|
|
131
|
+
result = run_one(klass, dry_run: dry_run)
|
|
132
|
+
results << result
|
|
133
|
+
break if result[:status] == :failed
|
|
134
|
+
end
|
|
135
|
+
results
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Run a single migration.
|
|
139
|
+
#
|
|
140
|
+
# @param migration_class_or_id [Class, String] Migration class or ID
|
|
141
|
+
# @param dry_run [Boolean] If true, preview without applying changes
|
|
142
|
+
# @return [Hash] Result hash with keys:
|
|
143
|
+
# - :migration_id [String] The migration identifier
|
|
144
|
+
# - :dry_run [Boolean] Whether this was a dry run
|
|
145
|
+
# - :status [Symbol] :success, :skipped, or :failed
|
|
146
|
+
# - :stats [Hash] Statistics from the migration
|
|
147
|
+
# - :error [String] Error message (if failed)
|
|
148
|
+
#
|
|
149
|
+
def run_one(migration_class_or_id, dry_run: false)
|
|
150
|
+
klass = resolve_migration(migration_class_or_id)
|
|
151
|
+
|
|
152
|
+
# Validate dependencies are applied
|
|
153
|
+
(klass.dependencies || []).each do |dep_id|
|
|
154
|
+
unless @registry.applied?(dep_id)
|
|
155
|
+
raise Familia::Migration::Errors::DependencyNotMet,
|
|
156
|
+
"Dependency #{dep_id} not applied for #{klass.migration_id}"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
instance = klass.new(run: !dry_run)
|
|
161
|
+
instance.prepare
|
|
162
|
+
|
|
163
|
+
result = {
|
|
164
|
+
migration_id: klass.migration_id,
|
|
165
|
+
dry_run: dry_run,
|
|
166
|
+
stats: {},
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
begin
|
|
170
|
+
if instance.migration_needed?
|
|
171
|
+
instance.migrate
|
|
172
|
+
result[:status] = :success
|
|
173
|
+
result[:stats] = instance.stats
|
|
174
|
+
@registry.record_applied(instance, instance.stats) unless dry_run
|
|
175
|
+
else
|
|
176
|
+
result[:status] = :skipped
|
|
177
|
+
end
|
|
178
|
+
rescue StandardError => e
|
|
179
|
+
result[:status] = :failed
|
|
180
|
+
result[:error] = e.message
|
|
181
|
+
@logger.error { "Migration failed: #{e.message}" }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
result
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Rollback a previously applied migration.
|
|
188
|
+
#
|
|
189
|
+
# @param migration_id [String] The migration identifier to rollback
|
|
190
|
+
# @return [Hash] Result hash with keys:
|
|
191
|
+
# - :migration_id [String] The migration identifier
|
|
192
|
+
# - :status [Symbol] :rolled_back or :failed
|
|
193
|
+
# - :error [String] Error message (if failed)
|
|
194
|
+
# @raise [Errors::NotApplied] if migration hasn't been applied
|
|
195
|
+
# @raise [Errors::HasDependents] if other migrations depend on this one
|
|
196
|
+
# @raise [Errors::NotReversible] if migration has no down method
|
|
197
|
+
#
|
|
198
|
+
def rollback(migration_id)
|
|
199
|
+
klass = resolve_migration(migration_id)
|
|
200
|
+
|
|
201
|
+
unless @registry.applied?(migration_id)
|
|
202
|
+
raise Familia::Migration::Errors::NotApplied,
|
|
203
|
+
"Migration #{migration_id} is not applied"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Batch fetch all applied migrations to check for dependents
|
|
207
|
+
applied_ids = @registry.all_applied.map { |e| e[:migration_id] }.to_set
|
|
208
|
+
|
|
209
|
+
# Check no dependents are applied
|
|
210
|
+
@migrations.each do |m|
|
|
211
|
+
if (m.dependencies || []).include?(migration_id) && applied_ids.include?(m.migration_id)
|
|
212
|
+
raise Familia::Migration::Errors::HasDependents,
|
|
213
|
+
"Cannot rollback: #{m.migration_id} depends on #{migration_id}"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
instance = klass.new
|
|
218
|
+
|
|
219
|
+
unless instance.reversible?
|
|
220
|
+
raise Familia::Migration::Errors::NotReversible,
|
|
221
|
+
"Migration #{migration_id} does not have a down method"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
result = { migration_id: migration_id }
|
|
225
|
+
|
|
226
|
+
begin
|
|
227
|
+
instance.down
|
|
228
|
+
@registry.record_rollback(migration_id)
|
|
229
|
+
result[:status] = :rolled_back
|
|
230
|
+
rescue StandardError => e
|
|
231
|
+
result[:status] = :failed
|
|
232
|
+
result[:error] = e.message
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
result
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
private
|
|
239
|
+
|
|
240
|
+
# Sort migrations in dependency order using Kahn's algorithm.
|
|
241
|
+
#
|
|
242
|
+
# @param migrations [Array<Class>] Migrations to sort
|
|
243
|
+
# @return [Array<Class>] Migrations in execution order
|
|
244
|
+
# @raise [Errors::CircularDependency] if a cycle is detected
|
|
245
|
+
#
|
|
246
|
+
def topological_sort(migrations)
|
|
247
|
+
return [] if migrations.empty?
|
|
248
|
+
|
|
249
|
+
# Build graph
|
|
250
|
+
id_to_class = migrations.each_with_object({}) { |m, h| h[m.migration_id] = m }
|
|
251
|
+
in_degree = Hash.new(0)
|
|
252
|
+
graph = Hash.new { |h, k| h[k] = [] }
|
|
253
|
+
|
|
254
|
+
migrations.each do |klass|
|
|
255
|
+
id = klass.migration_id
|
|
256
|
+
(klass.dependencies || []).each do |dep_id|
|
|
257
|
+
# Only consider dependencies that are in our migration set
|
|
258
|
+
next unless id_to_class.key?(dep_id)
|
|
259
|
+
|
|
260
|
+
graph[dep_id] << id
|
|
261
|
+
in_degree[id] += 1
|
|
262
|
+
end
|
|
263
|
+
in_degree[id] ||= 0 # Ensure entry exists
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Find nodes with no dependencies
|
|
267
|
+
queue = migrations.select { |m| in_degree[m.migration_id].zero? }
|
|
268
|
+
.map(&:migration_id)
|
|
269
|
+
result = []
|
|
270
|
+
|
|
271
|
+
until queue.empty?
|
|
272
|
+
id = queue.shift
|
|
273
|
+
result << id_to_class[id]
|
|
274
|
+
|
|
275
|
+
graph[id].each do |dependent_id|
|
|
276
|
+
in_degree[dependent_id] -= 1
|
|
277
|
+
queue << dependent_id if in_degree[dependent_id].zero?
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
if result.size != migrations.size
|
|
282
|
+
raise Familia::Migration::Errors::CircularDependency,
|
|
283
|
+
'Circular dependency detected in migrations'
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
result
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Resolve a migration class from a class or ID string.
|
|
290
|
+
#
|
|
291
|
+
# @param class_or_id [Class, String] Migration class or ID
|
|
292
|
+
# @return [Class] The migration class
|
|
293
|
+
# @raise [Errors::NotFound] if migration ID not found
|
|
294
|
+
# @raise [ArgumentError] if invalid argument type
|
|
295
|
+
#
|
|
296
|
+
def resolve_migration(class_or_id)
|
|
297
|
+
case class_or_id
|
|
298
|
+
when Class
|
|
299
|
+
class_or_id
|
|
300
|
+
when String
|
|
301
|
+
klass = @migrations.find { |m| m.migration_id == class_or_id }
|
|
302
|
+
raise Familia::Migration::Errors::NotFound, "Migration #{class_or_id} not found" unless klass
|
|
303
|
+
|
|
304
|
+
klass
|
|
305
|
+
else
|
|
306
|
+
raise ArgumentError, "Expected Class or String, got #{class_or_id.class}"
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest/sha1'
|
|
4
|
+
|
|
5
|
+
module Familia
|
|
6
|
+
module Migration
|
|
7
|
+
# Lua script registry for atomic Redis operations during migrations.
|
|
8
|
+
#
|
|
9
|
+
# Provides class-level registration and execution of Lua scripts with
|
|
10
|
+
# EVALSHA/EVAL fallback pattern for efficiency. Scripts are precomputed
|
|
11
|
+
# with their SHA1 hashes at registration time.
|
|
12
|
+
#
|
|
13
|
+
# @example Registering a custom script
|
|
14
|
+
# Familia::Migration::Script.register(:my_script, <<~LUA)
|
|
15
|
+
# local key = KEYS[1]
|
|
16
|
+
# return redis.call('GET', key)
|
|
17
|
+
# LUA
|
|
18
|
+
#
|
|
19
|
+
# @example Executing a script
|
|
20
|
+
# result = Familia::Migration::Script.execute(
|
|
21
|
+
# redis,
|
|
22
|
+
# :rename_field,
|
|
23
|
+
# keys: ['user:123'],
|
|
24
|
+
# argv: ['old_name', 'new_name']
|
|
25
|
+
# )
|
|
26
|
+
#
|
|
27
|
+
class Script
|
|
28
|
+
# Error raised when a script is not found in the registry
|
|
29
|
+
class ScriptNotFound < Familia::Migration::Errors::MigrationError; end
|
|
30
|
+
|
|
31
|
+
# Error raised when script execution fails
|
|
32
|
+
class ScriptError < Familia::Migration::Errors::MigrationError; end
|
|
33
|
+
|
|
34
|
+
# Holds script source and precomputed SHA1
|
|
35
|
+
ScriptEntry = Data.define(:source, :sha) do
|
|
36
|
+
def initialize(source:, sha: nil)
|
|
37
|
+
computed_sha = sha || Digest::SHA1.hexdigest(source)
|
|
38
|
+
super(source: source.freeze, sha: computed_sha.freeze)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# Access the script registry
|
|
44
|
+
#
|
|
45
|
+
# @return [Hash{Symbol => ScriptEntry}] Frozen hash of registered scripts
|
|
46
|
+
def scripts
|
|
47
|
+
@scripts ||= {}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Register a Lua script with the given name
|
|
51
|
+
#
|
|
52
|
+
# @param name [Symbol] Unique identifier for the script
|
|
53
|
+
# @param lua_source [String] The Lua script source code
|
|
54
|
+
# @return [ScriptEntry] The registered script entry
|
|
55
|
+
# @raise [ArgumentError] If name is not a Symbol or source is empty
|
|
56
|
+
def register(name, lua_source)
|
|
57
|
+
raise ArgumentError, 'Script name must be a Symbol' unless name.is_a?(Symbol)
|
|
58
|
+
raise ArgumentError, 'Lua source cannot be empty' if lua_source.nil? || lua_source.strip.empty?
|
|
59
|
+
|
|
60
|
+
entry = ScriptEntry.new(source: lua_source.strip)
|
|
61
|
+
scripts[name] = entry
|
|
62
|
+
entry
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Execute a registered script with EVALSHA/EVAL fallback
|
|
66
|
+
#
|
|
67
|
+
# Attempts EVALSHA first for efficiency. If the script is not cached
|
|
68
|
+
# on the Redis server (NOSCRIPT error), falls back to EVAL which
|
|
69
|
+
# also caches the script for future calls.
|
|
70
|
+
#
|
|
71
|
+
# @param redis [Redis] Redis client connection
|
|
72
|
+
# @param name [Symbol] Name of the registered script
|
|
73
|
+
# @param keys [Array<String>] KEYS array for the Lua script
|
|
74
|
+
# @param argv [Array] ARGV array for the Lua script
|
|
75
|
+
# @return [Object] The script's return value
|
|
76
|
+
# @raise [ScriptNotFound] If the script name is not registered
|
|
77
|
+
# @raise [ScriptError] If script execution fails (other than NOSCRIPT)
|
|
78
|
+
def execute(redis, name, keys: [], argv: [])
|
|
79
|
+
entry = scripts[name]
|
|
80
|
+
raise ScriptNotFound, "Script not found: #{name}" unless entry
|
|
81
|
+
|
|
82
|
+
execute_with_fallback(redis, entry, keys, argv, name)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Preload all registered scripts to the Redis server
|
|
86
|
+
#
|
|
87
|
+
# Loads scripts using SCRIPT LOAD so subsequent EVALSHA calls
|
|
88
|
+
# will succeed without fallback. Useful at application startup.
|
|
89
|
+
#
|
|
90
|
+
# @param redis [Redis] Redis client connection
|
|
91
|
+
# @return [Hash{Symbol => String}] Map of script names to their SHAs
|
|
92
|
+
def preload_all(redis)
|
|
93
|
+
scripts.each_with_object({}) do |(name, entry), loaded|
|
|
94
|
+
sha = redis.script(:load, entry.source)
|
|
95
|
+
loaded[name] = sha
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Check if a script is registered
|
|
100
|
+
#
|
|
101
|
+
# @param name [Symbol] Script name to check
|
|
102
|
+
# @return [Boolean] true if the script exists
|
|
103
|
+
def registered?(name)
|
|
104
|
+
scripts.key?(name)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Get the SHA for a registered script
|
|
108
|
+
#
|
|
109
|
+
# @param name [Symbol] Script name
|
|
110
|
+
# @return [String, nil] The script's SHA1 hash or nil if not found
|
|
111
|
+
def sha_for(name)
|
|
112
|
+
scripts[name]&.sha
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Reset the registry (primarily for testing)
|
|
116
|
+
#
|
|
117
|
+
# @return [void]
|
|
118
|
+
def reset!
|
|
119
|
+
@scripts = {}
|
|
120
|
+
register_builtin_scripts
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
# Execute script with EVALSHA, falling back to full script on NOSCRIPT
|
|
126
|
+
def execute_with_fallback(redis, entry, keys, argv, name)
|
|
127
|
+
redis.evalsha(entry.sha, keys: keys, argv: argv)
|
|
128
|
+
rescue Redis::CommandError => e
|
|
129
|
+
if e.message.include?('NOSCRIPT')
|
|
130
|
+
# Script not cached on server, send full script (also caches it)
|
|
131
|
+
redis.call('EVAL', entry.source, keys.size, *keys, *argv)
|
|
132
|
+
else
|
|
133
|
+
raise ScriptError, "Script execution failed for #{name}: #{e.message}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Register all built-in migration scripts
|
|
138
|
+
def register_builtin_scripts
|
|
139
|
+
register_rename_field
|
|
140
|
+
register_copy_field
|
|
141
|
+
register_delete_field
|
|
142
|
+
register_rename_key_preserve_ttl
|
|
143
|
+
register_backup_and_modify_field
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def register_rename_field
|
|
147
|
+
register(:rename_field, <<~LUA)
|
|
148
|
+
local key = KEYS[1]
|
|
149
|
+
local old_field = ARGV[1]
|
|
150
|
+
local new_field = ARGV[2]
|
|
151
|
+
|
|
152
|
+
if redis.call('HEXISTS', key, new_field) == 1 then
|
|
153
|
+
return redis.error_reply('Target field already exists: ' .. new_field)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
local val = redis.call('HGET', key, old_field)
|
|
157
|
+
if val then
|
|
158
|
+
redis.call('HSET', key, new_field, val)
|
|
159
|
+
redis.call('HDEL', key, old_field)
|
|
160
|
+
return 1
|
|
161
|
+
end
|
|
162
|
+
return 0
|
|
163
|
+
LUA
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def register_copy_field
|
|
167
|
+
register(:copy_field, <<~LUA)
|
|
168
|
+
local key = KEYS[1]
|
|
169
|
+
local src_field = ARGV[1]
|
|
170
|
+
local dst_field = ARGV[2]
|
|
171
|
+
|
|
172
|
+
local val = redis.call('HGET', key, src_field)
|
|
173
|
+
if val then
|
|
174
|
+
redis.call('HSET', key, dst_field, val)
|
|
175
|
+
return 1
|
|
176
|
+
end
|
|
177
|
+
return 0
|
|
178
|
+
LUA
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def register_delete_field
|
|
182
|
+
register(:delete_field, <<~LUA)
|
|
183
|
+
local key = KEYS[1]
|
|
184
|
+
local field = ARGV[1]
|
|
185
|
+
return redis.call('HDEL', key, field)
|
|
186
|
+
LUA
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def register_rename_key_preserve_ttl
|
|
190
|
+
register(:rename_key_preserve_ttl, <<~LUA)
|
|
191
|
+
local src = KEYS[1]
|
|
192
|
+
local dst = KEYS[2]
|
|
193
|
+
|
|
194
|
+
if redis.call('EXISTS', dst) == 1 then
|
|
195
|
+
return redis.error_reply('Destination key already exists')
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
local ttl = redis.call('PTTL', src)
|
|
199
|
+
redis.call('RENAME', src, dst)
|
|
200
|
+
|
|
201
|
+
if ttl > 0 then
|
|
202
|
+
redis.call('PEXPIRE', dst, ttl)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
return ttl
|
|
206
|
+
LUA
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def register_backup_and_modify_field
|
|
210
|
+
register(:backup_and_modify_field, <<~LUA)
|
|
211
|
+
local hash_key = KEYS[1]
|
|
212
|
+
local backup_key = KEYS[2]
|
|
213
|
+
local field = ARGV[1]
|
|
214
|
+
local new_value = ARGV[2]
|
|
215
|
+
local ttl = tonumber(ARGV[3])
|
|
216
|
+
|
|
217
|
+
local old_val = redis.call('HGET', hash_key, field)
|
|
218
|
+
if old_val then
|
|
219
|
+
redis.call('HSET', backup_key, hash_key .. ':' .. field, old_val)
|
|
220
|
+
if ttl and ttl > 0 then
|
|
221
|
+
redis.call('EXPIRE', backup_key, ttl)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
redis.call('HSET', hash_key, field, new_value)
|
|
225
|
+
return old_val
|
|
226
|
+
LUA
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Register built-in scripts when the class is loaded
|
|
231
|
+
register_builtin_scripts
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'migration/errors'
|
|
4
|
+
require_relative 'migration/script'
|
|
5
|
+
require_relative 'migration/registry'
|
|
6
|
+
require_relative 'migration/runner'
|
|
7
|
+
require_relative 'migration/base'
|
|
8
|
+
require_relative 'migration/model'
|
|
9
|
+
require_relative 'migration/pipeline'
|
|
10
|
+
|
|
11
|
+
module Familia
|
|
12
|
+
module Migration
|
|
13
|
+
class << self
|
|
14
|
+
# Registered migration classes (populated by Base.inherited)
|
|
15
|
+
def migrations
|
|
16
|
+
@migrations ||= []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def migrations=(list)
|
|
20
|
+
@migrations = list
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Configuration
|
|
24
|
+
def config
|
|
25
|
+
@config ||= Configuration.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def configure
|
|
29
|
+
yield config if block_given?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class Configuration
|
|
34
|
+
attr_accessor :migrations_key, :backup_ttl, :batch_size
|
|
35
|
+
|
|
36
|
+
def initialize
|
|
37
|
+
@migrations_key = 'familia:migrations'
|
|
38
|
+
@backup_ttl = 86_400 # 24 hours
|
|
39
|
+
@batch_size = 1000
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|