cold_storage 0.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 +7 -0
- data/CHANGELOG.md +35 -0
- data/LICENSE.txt +21 -0
- data/README.md +399 -0
- data/lib/cold_storage/archivable.rb +144 -0
- data/lib/cold_storage/archive_model.rb +181 -0
- data/lib/cold_storage/archive_record.rb +116 -0
- data/lib/cold_storage/archiver.rb +283 -0
- data/lib/cold_storage/association_tree.rb +26 -0
- data/lib/cold_storage/configuration.rb +105 -0
- data/lib/cold_storage/internal_schema.rb +56 -0
- data/lib/cold_storage/jobs.rb +61 -0
- data/lib/cold_storage/logging.rb +18 -0
- data/lib/cold_storage/metadata.rb +33 -0
- data/lib/cold_storage/model.rb +48 -0
- data/lib/cold_storage/policy.rb +212 -0
- data/lib/cold_storage/railtie.rb +51 -0
- data/lib/cold_storage/registry.rb +60 -0
- data/lib/cold_storage/report.rb +50 -0
- data/lib/cold_storage/restorer.rb +194 -0
- data/lib/cold_storage/row_reader.rb +31 -0
- data/lib/cold_storage/run.rb +51 -0
- data/lib/cold_storage/schema_mirror.rb +337 -0
- data/lib/cold_storage/tasks/cold_storage.rake +102 -0
- data/lib/cold_storage/version.rb +5 -0
- data/lib/cold_storage.rb +139 -0
- data/lib/generators/cold_storage/install/install_generator.rb +39 -0
- data/lib/generators/cold_storage/install/templates/initializer.rb.tt +36 -0
- metadata +105 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ColdStorage
|
|
4
|
+
# Human readable status of every archivable model: what its rule is, how many
|
|
5
|
+
# rows are waiting, how many are already archived, when it last ran.
|
|
6
|
+
class Report
|
|
7
|
+
HEADERS = ['Model', 'Rule', 'Pending', 'Archived', 'Last run'].freeze
|
|
8
|
+
|
|
9
|
+
def initialize(models: nil, counts: true)
|
|
10
|
+
@models = models || ColdStorage.models
|
|
11
|
+
@counts = counts
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def rows
|
|
15
|
+
@rows ||= @models.map { |model| row_for(model) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_s
|
|
19
|
+
table = [HEADERS, *rows]
|
|
20
|
+
widths = HEADERS.each_index.map { |i| table.map { |row| row[i].to_s.length }.max }
|
|
21
|
+
separator = widths.map { |width| '-' * width }.join('-+-')
|
|
22
|
+
|
|
23
|
+
lines = table.map { |row| row.each_with_index.map { |cell, i| cell.to_s.ljust(widths[i]) }.join(' | ') }
|
|
24
|
+
lines.insert(1, separator)
|
|
25
|
+
lines.join("\n")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def row_for(model)
|
|
31
|
+
[
|
|
32
|
+
model.name,
|
|
33
|
+
model.archiving_policy.to_s,
|
|
34
|
+
count { model.archivable_records.count },
|
|
35
|
+
count { model.archived.count },
|
|
36
|
+
Run.last_success_at(model)&.strftime('%Y-%m-%d %H:%M') || 'never'
|
|
37
|
+
]
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
[model.name, 'error', e.class.to_s, '-', '-']
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def count
|
|
43
|
+
return '-' unless @counts
|
|
44
|
+
|
|
45
|
+
yield
|
|
46
|
+
rescue ActiveRecord::StatementInvalid
|
|
47
|
+
'n/a'
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ColdStorage
|
|
4
|
+
# Moves rows back from the archive database into the primary one, optionally
|
|
5
|
+
# taking their archived children along.
|
|
6
|
+
#
|
|
7
|
+
# ColdStorage.restore(Invoice, [1, 2])
|
|
8
|
+
# ColdStorage.restore(Invoice, [1, 2], with: :all)
|
|
9
|
+
# ColdStorage.restore(Invoice, [1, 2], with: [:invoice_lines])
|
|
10
|
+
# ColdStorage.restore(Invoice, [1, 2], with: { invoice_lines: [:taxes] })
|
|
11
|
+
#
|
|
12
|
+
# Parents are written before their children, so foreign keys in the primary
|
|
13
|
+
# database hold at every step.
|
|
14
|
+
class Restorer
|
|
15
|
+
include Logging
|
|
16
|
+
include RowReader
|
|
17
|
+
|
|
18
|
+
attr_reader :model
|
|
19
|
+
|
|
20
|
+
# @param with [Symbol, Array, Hash, nil] associations to restore too;
|
|
21
|
+
# `:all` walks every has_many/has_one that has an archive table
|
|
22
|
+
# @param delete_from_archive [Boolean] remove the rows from the archive
|
|
23
|
+
# once they are back in the primary database
|
|
24
|
+
def initialize(model, with: nil, delete_from_archive: true, batch_size: nil,
|
|
25
|
+
dry_run: ColdStorage.config.dry_run, visited: [])
|
|
26
|
+
@model = model
|
|
27
|
+
@with = with
|
|
28
|
+
@delete_from_archive = delete_from_archive
|
|
29
|
+
@batch_size = batch_size || ColdStorage.config.batch_size
|
|
30
|
+
@dry_run = dry_run
|
|
31
|
+
@visited = visited
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param ids [Array, ActiveRecord::Relation] ids to restore, or a relation
|
|
35
|
+
# on the archive model (Invoice.archived.where(...))
|
|
36
|
+
# @return [Integer] number of restored rows, children included
|
|
37
|
+
def call(ids)
|
|
38
|
+
validate_associations!
|
|
39
|
+
|
|
40
|
+
relation = normalize(ids)
|
|
41
|
+
restored = 0
|
|
42
|
+
own = 0
|
|
43
|
+
|
|
44
|
+
relation.in_batches(of: @batch_size) do |batch|
|
|
45
|
+
rows = read(batch)
|
|
46
|
+
next if rows.empty?
|
|
47
|
+
|
|
48
|
+
own += write(rows)
|
|
49
|
+
restored_ids = rows.map { |row| row[primary_key] }
|
|
50
|
+
restored += restore_associations(restored_ids)
|
|
51
|
+
delete(restored_ids)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
log("#{model}: restored #{own} row(s) from the archive#{' [dry run]' if @dry_run}")
|
|
55
|
+
own + restored
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The associations `with:` resolves to, as association name => nested with.
|
|
59
|
+
# @return [Hash{Symbol => Object}]
|
|
60
|
+
def associations
|
|
61
|
+
@associations ||= expand(@with)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def primary_key
|
|
67
|
+
@primary_key ||= model.primary_key
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def archive_model
|
|
71
|
+
@archive_model ||= ArchiveModel.for(model)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def normalize(ids)
|
|
75
|
+
return ids if ids.is_a?(ActiveRecord::Relation)
|
|
76
|
+
|
|
77
|
+
archive_model.where(primary_key => Array(ids))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def read(batch)
|
|
81
|
+
sql = batch.select(archive_model.arel_table[Arel.star]).to_sql
|
|
82
|
+
rows = ArchiveRecord.with_archive_connection do |connection|
|
|
83
|
+
select_rows_as_hashes(connection, sql, "#{model} Restore Load")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
stamp = ColdStorage.config.archived_at_column
|
|
87
|
+
rows.each { |row| row.delete(stamp.to_s) } if stamp
|
|
88
|
+
rows
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def write(rows)
|
|
92
|
+
return rows.size if @dry_run
|
|
93
|
+
|
|
94
|
+
# unscoped: a default scope would otherwise force its own values (a
|
|
95
|
+
# soft-delete scope would resurrect rows as "not deleted").
|
|
96
|
+
model.unscoped.upsert_all(rows, unique_by: primary_key.to_sym, record_timestamps: false, returning: false)
|
|
97
|
+
rows.size
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def delete(ids)
|
|
101
|
+
return unless @delete_from_archive && !@dry_run
|
|
102
|
+
|
|
103
|
+
archive_model.where(primary_key => ids).delete_all
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------ associations
|
|
107
|
+
|
|
108
|
+
# Checked before anything is written: a typo in `with:` must not leave a
|
|
109
|
+
# half restored graph behind.
|
|
110
|
+
def validate_associations!
|
|
111
|
+
associations.each_key do |name|
|
|
112
|
+
reflection = reflection_for(name)
|
|
113
|
+
assert_archive_table!(reflection.klass, name)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def restore_associations(parent_ids)
|
|
118
|
+
return 0 if associations.empty?
|
|
119
|
+
|
|
120
|
+
associations.sum do |name, nested|
|
|
121
|
+
reflection = reflection_for(name)
|
|
122
|
+
child = reflection.klass
|
|
123
|
+
|
|
124
|
+
self.class.new(
|
|
125
|
+
child,
|
|
126
|
+
with: nested,
|
|
127
|
+
delete_from_archive: @delete_from_archive,
|
|
128
|
+
batch_size: @batch_size,
|
|
129
|
+
dry_run: @dry_run,
|
|
130
|
+
visited: @visited + [model.table_name]
|
|
131
|
+
).call(child_relation(reflection, parent_ids))
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def child_relation(reflection, parent_ids)
|
|
136
|
+
scope = ArchiveModel.for(reflection.klass).where(reflection.foreign_key => parent_ids)
|
|
137
|
+
scope = scope.where(reflection.type => model.polymorphic_name) if reflection.type
|
|
138
|
+
scope
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def reflection_for(name)
|
|
142
|
+
reflection = model.reflect_on_association(name)
|
|
143
|
+
raise ArgumentError, "#{model} has no association #{name.inspect} to restore" if reflection.nil?
|
|
144
|
+
|
|
145
|
+
if reflection.macro == :belongs_to
|
|
146
|
+
raise ArgumentError,
|
|
147
|
+
"#{model}##{name} is a belongs_to; restore the owner first, then its children"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
reflection
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def assert_archive_table!(child, name)
|
|
154
|
+
exists = ArchiveRecord.with_archive_connection { |c| c.table_exists?(child.table_name) }
|
|
155
|
+
return if exists
|
|
156
|
+
|
|
157
|
+
raise SchemaMissingError,
|
|
158
|
+
"The archive database has no #{child.table_name} table, so #{model}##{name} cannot be restored."
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# `with:` accepts a symbol, an array, a nested hash, or :all.
|
|
162
|
+
def expand(value)
|
|
163
|
+
return every_child_association if value == true || value == :all
|
|
164
|
+
|
|
165
|
+
AssociationTree.normalize(value)
|
|
166
|
+
rescue ArgumentError
|
|
167
|
+
raise ArgumentError, "with: expects a symbol, array, hash or :all, got #{value.inspect}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Every child association that actually has something to restore. Cycles
|
|
171
|
+
# are cut with the tables already visited on the way down.
|
|
172
|
+
def every_child_association
|
|
173
|
+
model.reflect_on_all_associations.each_with_object({}) do |reflection, result|
|
|
174
|
+
next unless %i[has_many has_one].include?(reflection.macro)
|
|
175
|
+
next if reflection.options[:through] || reflection.options[:polymorphic]
|
|
176
|
+
|
|
177
|
+
target = child_table(reflection)
|
|
178
|
+
next if target.nil? || @visited.include?(target)
|
|
179
|
+
|
|
180
|
+
result[reflection.name] = :all
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def child_table(reflection)
|
|
185
|
+
table = reflection.klass.table_name
|
|
186
|
+
return nil if table.blank? || table == model.table_name
|
|
187
|
+
|
|
188
|
+
exists = ArchiveRecord.with_archive_connection { |c| c.table_exists?(table) }
|
|
189
|
+
exists ? table : nil
|
|
190
|
+
rescue StandardError
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ColdStorage
|
|
4
|
+
# Reads rows as plain hashes, cast by the *database* column types rather than
|
|
5
|
+
# by the model's attribute types.
|
|
6
|
+
#
|
|
7
|
+
# This is what makes a copy faithful: model level serializers, enums and
|
|
8
|
+
# default scopes are bypassed on both sides, while jsonb stays a Hash, arrays
|
|
9
|
+
# stay Arrays and numerics stay BigDecimals - so the same values are written
|
|
10
|
+
# back into identically typed columns on the other side.
|
|
11
|
+
module RowReader
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter]
|
|
15
|
+
# @param sql [String]
|
|
16
|
+
# @return [Array<Hash{String => Object}>]
|
|
17
|
+
def select_rows_as_hashes(connection, sql, name)
|
|
18
|
+
result = connection.select_all(sql, name)
|
|
19
|
+
return [] if result.empty?
|
|
20
|
+
|
|
21
|
+
columns = result.columns
|
|
22
|
+
values = result.cast_values
|
|
23
|
+
|
|
24
|
+
if columns.size == 1
|
|
25
|
+
values.map { |value| { columns.first => value } }
|
|
26
|
+
else
|
|
27
|
+
values.map { |row| columns.zip(row).to_h }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ColdStorage
|
|
4
|
+
# One archiving run of one model. Lives in the archive database, and is what
|
|
5
|
+
# makes the `every:` option work across processes and deploys.
|
|
6
|
+
class Run < ArchiveRecord
|
|
7
|
+
self.table_name = InternalSchema::RUNS_TABLE
|
|
8
|
+
|
|
9
|
+
STATUSES = %w[running succeeded failed].freeze
|
|
10
|
+
|
|
11
|
+
scope :succeeded, -> { where(status: 'succeeded') }
|
|
12
|
+
scope :for_model, ->(model) { where(archived_model: model.to_s) }
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
# @return [Time, nil] when the model was last archived successfully
|
|
16
|
+
def last_success_at(model)
|
|
17
|
+
InternalSchema.ensure!
|
|
18
|
+
for_model(model).succeeded.maximum(:finished_at)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def start!(model)
|
|
22
|
+
InternalSchema.ensure!
|
|
23
|
+
create!(
|
|
24
|
+
archived_model: model.to_s,
|
|
25
|
+
archived_table: (model.table_name if model.respond_to?(:table_name)),
|
|
26
|
+
status: 'running',
|
|
27
|
+
started_at: Time.current
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def succeed!(archived_count:, deleted_count:)
|
|
33
|
+
update!(
|
|
34
|
+
status: 'succeeded',
|
|
35
|
+
archived_count: archived_count,
|
|
36
|
+
deleted_count: deleted_count,
|
|
37
|
+
finished_at: Time.current
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def fail!(error, archived_count: 0, deleted_count: 0)
|
|
42
|
+
update!(
|
|
43
|
+
status: 'failed',
|
|
44
|
+
archived_count: archived_count,
|
|
45
|
+
deleted_count: deleted_count,
|
|
46
|
+
finished_at: Time.current,
|
|
47
|
+
error_message: "#{error.class}: #{error.message}"
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module ColdStorage
|
|
6
|
+
# Copies the table definitions of the archivable models (and of the tables
|
|
7
|
+
# they cascade into) from the primary database to the archive database, and
|
|
8
|
+
# keeps them in sync when migrations change them.
|
|
9
|
+
#
|
|
10
|
+
# Deliberate differences from the source schema:
|
|
11
|
+
# * no foreign keys - an archive holds partial object graphs
|
|
12
|
+
# * no NOT NULL, no default values (configurable) - a schema that got
|
|
13
|
+
# stricter later must never reject rows archived before that
|
|
14
|
+
# * columns are added, never dropped (configurable) - archived rows keep
|
|
15
|
+
# the columns they were archived with
|
|
16
|
+
# * one extra column, `archived_at`
|
|
17
|
+
class SchemaMirror
|
|
18
|
+
include Logging
|
|
19
|
+
|
|
20
|
+
# A single difference between the two schemas.
|
|
21
|
+
Change = Struct.new(:type, :table, :name, :detail, keyword_init: true) do
|
|
22
|
+
def to_s
|
|
23
|
+
[type, table, name, detail].compact.join(' ')
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
attr_reader :changes
|
|
28
|
+
|
|
29
|
+
# @param models [Array<Class>, nil] defaults to every archivable model
|
|
30
|
+
# @param dry_run [Boolean] plan only, never touch the archive database
|
|
31
|
+
def initialize(models: nil, dry_run: ColdStorage.config.dry_run, logger_prefix: nil)
|
|
32
|
+
@models = models
|
|
33
|
+
@dry_run = dry_run
|
|
34
|
+
@logger_prefix = logger_prefix
|
|
35
|
+
@changes = []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Applies the changes.
|
|
39
|
+
# @return [Array<Change>] what was applied
|
|
40
|
+
def sync!
|
|
41
|
+
run(apply: !@dry_run)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Array<Change>] what sync! would apply; empty means "in sync"
|
|
45
|
+
def plan
|
|
46
|
+
run(apply: false)
|
|
47
|
+
end
|
|
48
|
+
alias check plan
|
|
49
|
+
|
|
50
|
+
# Tables the archive database is expected to hold, derived from the models.
|
|
51
|
+
# @return [Hash{String => Class}] table name => source model
|
|
52
|
+
def tables
|
|
53
|
+
@tables ||= begin
|
|
54
|
+
map = {}
|
|
55
|
+
source_models.each { |model| collect_tables(model, map) }
|
|
56
|
+
map
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def source_models
|
|
61
|
+
@source_models ||= (@models || ColdStorage.models).map { |m| m.is_a?(String) ? m.constantize : m }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def run(apply:)
|
|
67
|
+
@changes = []
|
|
68
|
+
ArchiveRecord.connect!
|
|
69
|
+
InternalSchema.ensure! if apply
|
|
70
|
+
|
|
71
|
+
ArchiveRecord.with_archive_connection do |target|
|
|
72
|
+
tables.each { |table, model| mirror_table(model, table, target, apply) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
record_schema_version if apply
|
|
76
|
+
log_summary(apply)
|
|
77
|
+
changes
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# ------------------------------------------------------------------ tables
|
|
81
|
+
|
|
82
|
+
def collect_tables(model, map, cascade = nil, seen = [])
|
|
83
|
+
return if seen.include?(model.table_name)
|
|
84
|
+
|
|
85
|
+
seen += [model.table_name]
|
|
86
|
+
map[model.table_name] ||= model
|
|
87
|
+
|
|
88
|
+
tree = cascade || policy_cascade(model)
|
|
89
|
+
tree.each do |association, nested|
|
|
90
|
+
reflection = Policy.cascade_reflection!(model, association)
|
|
91
|
+
child_tree = nested.nil? ? policy_cascade(reflection.klass) : AssociationTree.normalize(nested)
|
|
92
|
+
collect_tables(reflection.klass, map, child_tree, seen)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def policy_cascade(model)
|
|
97
|
+
return {} unless model.respond_to?(:archiving_policy) && model.archiving_policy
|
|
98
|
+
|
|
99
|
+
model.archiving_policy.cascade
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def mirror_table(model, table, target, apply)
|
|
103
|
+
source_columns = columns_for(model)
|
|
104
|
+
mirror_enum_types(model, source_columns, target, apply)
|
|
105
|
+
|
|
106
|
+
if target.table_exists?(table)
|
|
107
|
+
sync_columns(table, source_columns, target, apply)
|
|
108
|
+
else
|
|
109
|
+
create_table(model, table, source_columns, target, apply)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
ensure_archived_at(table, source_columns, target, apply)
|
|
113
|
+
sync_indexes(model, table, target, apply) if ColdStorage.config.mirror_indexes
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def create_table(model, table, source_columns, target, apply)
|
|
117
|
+
record(:create_table, table, nil, "#{source_columns.size} columns")
|
|
118
|
+
return unless apply
|
|
119
|
+
|
|
120
|
+
target.create_table(table, id: false) do |t|
|
|
121
|
+
source_columns.each do |column|
|
|
122
|
+
t.column(column.name, sql_type_for(column), **column_options(column))
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
primary_key = model.primary_key
|
|
127
|
+
return if primary_key.blank?
|
|
128
|
+
|
|
129
|
+
quoted = Array(primary_key).map { |key| target.quote_column_name(key) }.join(', ')
|
|
130
|
+
target.execute("ALTER TABLE #{target.quote_table_name(table)} ADD PRIMARY KEY (#{quoted})")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def sync_columns(table, source_columns, target, apply)
|
|
134
|
+
archive_columns = target.columns(table).index_by(&:name)
|
|
135
|
+
|
|
136
|
+
source_columns.each do |column|
|
|
137
|
+
existing = archive_columns[column.name]
|
|
138
|
+
|
|
139
|
+
if existing.nil?
|
|
140
|
+
record(:add_column, table, column.name, sql_type_for(column))
|
|
141
|
+
target.add_column(table, column.name, sql_type_for(column), **column_options(column)) if apply
|
|
142
|
+
elsif normalize_type(sql_type_for(existing)) != normalize_type(sql_type_for(column))
|
|
143
|
+
handle_type_mismatch(table, column, existing, target, apply)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
removed = archive_columns.keys - source_columns.map(&:name) - [archived_at_column.to_s]
|
|
148
|
+
removed.each { |name| handle_removed_column(table, name, target, apply) }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def handle_type_mismatch(table, column, existing, target, apply)
|
|
152
|
+
from = sql_type_for(existing)
|
|
153
|
+
to = sql_type_for(column)
|
|
154
|
+
message = "#{table}.#{column.name} is #{from} in the archive but #{to} in the primary database"
|
|
155
|
+
|
|
156
|
+
case ColdStorage.config.on_type_mismatch
|
|
157
|
+
when :raise
|
|
158
|
+
raise Error, message
|
|
159
|
+
when :change
|
|
160
|
+
record(:change_column, table, column.name, "#{from} -> #{to}")
|
|
161
|
+
target.change_column(table, column.name, to, **column_options(column)) if apply
|
|
162
|
+
when :warn
|
|
163
|
+
record(:type_mismatch, table, column.name, "#{from} != #{to}")
|
|
164
|
+
warn_log(message)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def handle_removed_column(table, name, target, apply)
|
|
169
|
+
if ColdStorage.config.drop_removed_columns
|
|
170
|
+
record(:remove_column, table, name, nil)
|
|
171
|
+
target.remove_column(table, name) if apply
|
|
172
|
+
else
|
|
173
|
+
record(:extra_column, table, name, 'kept (drop_removed_columns is false)')
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def ensure_archived_at(table, source_columns, target, apply)
|
|
178
|
+
column = archived_at_column
|
|
179
|
+
return if column.nil?
|
|
180
|
+
|
|
181
|
+
if source_columns.any? { |c| c.name == column.to_s }
|
|
182
|
+
warn_log("#{table} already has a #{column} column; ColdStorage will not stamp it")
|
|
183
|
+
return
|
|
184
|
+
end
|
|
185
|
+
return if target.table_exists?(table) && target.column_exists?(table, column)
|
|
186
|
+
|
|
187
|
+
record(:add_column, table, column.to_s, 'datetime (archived_at)')
|
|
188
|
+
return unless apply
|
|
189
|
+
|
|
190
|
+
target.add_column(table, column, :datetime)
|
|
191
|
+
target.add_index(table, column, name: index_name(table, [column.to_s], 'archived_at'))
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# ----------------------------------------------------------------- indexes
|
|
195
|
+
|
|
196
|
+
def sync_indexes(model, table, target, apply)
|
|
197
|
+
return if !apply && !target.table_exists?(table)
|
|
198
|
+
|
|
199
|
+
source_indexes = with_source_connection(model) { |source| source.indexes(table) }
|
|
200
|
+
existing = target.indexes(table).map(&:name)
|
|
201
|
+
|
|
202
|
+
source_indexes.each do |index|
|
|
203
|
+
next if existing.include?(index.name)
|
|
204
|
+
|
|
205
|
+
detail = Array(index.columns).join(', ')
|
|
206
|
+
detail += ' (unique dropped)' if index.unique
|
|
207
|
+
record(:add_index, table, index.name, detail)
|
|
208
|
+
next unless apply
|
|
209
|
+
|
|
210
|
+
add_index(target, table, index)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Unique indexes are mirrored as plain ones. An archive accumulates history:
|
|
215
|
+
# a natural key that is unique in the primary database at any one moment
|
|
216
|
+
# (say working_date + colleague_id) is not unique across everything that
|
|
217
|
+
# table ever held. The primary key is the only uniqueness the archive
|
|
218
|
+
# enforces, and it is what keeps re-archiving a row idempotent.
|
|
219
|
+
def add_index(target, table, index)
|
|
220
|
+
options = {
|
|
221
|
+
name: index.name,
|
|
222
|
+
unique: false,
|
|
223
|
+
where: index.where,
|
|
224
|
+
using: index.using,
|
|
225
|
+
order: index.orders.presence,
|
|
226
|
+
opclass: index.opclasses.presence
|
|
227
|
+
}.compact
|
|
228
|
+
|
|
229
|
+
target.add_index(table, index.columns, **options)
|
|
230
|
+
rescue StandardError => e
|
|
231
|
+
# An index is never worth failing a sync for: the data still fits.
|
|
232
|
+
warn_log("could not mirror index #{index.name} on #{table}: #{e.class}: #{e.message}")
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# ------------------------------------------------------------- enum types
|
|
236
|
+
|
|
237
|
+
def mirror_enum_types(model, source_columns, target, apply)
|
|
238
|
+
return unless target.respond_to?(:enum_types)
|
|
239
|
+
|
|
240
|
+
source_enums = enum_types_for(model)
|
|
241
|
+
return if source_enums.empty?
|
|
242
|
+
|
|
243
|
+
existing = target.enum_types.to_h.keys
|
|
244
|
+
used = source_columns.map { |column| base_type(column.sql_type) }.uniq
|
|
245
|
+
|
|
246
|
+
used.each do |type|
|
|
247
|
+
values = source_enums[type]
|
|
248
|
+
next if values.nil? || existing.include?(type)
|
|
249
|
+
|
|
250
|
+
record(:create_enum, nil, type, Array(values).join(', '))
|
|
251
|
+
target.create_enum(type, Array(values)) if apply
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def enum_types_for(model)
|
|
256
|
+
@enum_types ||= {}
|
|
257
|
+
@enum_types[model.connection_pool.db_config.name] ||=
|
|
258
|
+
with_source_connection(model) do |source|
|
|
259
|
+
source.respond_to?(:enum_types) ? source.enum_types.to_h : {}
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def base_type(sql_type)
|
|
264
|
+
sql_type.to_s.sub(/\[\]\z/, '').sub(/\(.*\)\z/, '').strip
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# ------------------------------------------------------------------ shared
|
|
268
|
+
|
|
269
|
+
def columns_for(model)
|
|
270
|
+
with_source_connection(model) { |source| source.columns(model.table_name) }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def with_source_connection(model, &block)
|
|
274
|
+
model.connection_pool.with_connection(&block)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def column_options(column)
|
|
278
|
+
options = {}
|
|
279
|
+
options[:null] = column.null if ColdStorage.config.mirror_null_constraints
|
|
280
|
+
if ColdStorage.config.mirror_defaults && column.default_function.nil?
|
|
281
|
+
options[:default] = column.default
|
|
282
|
+
end
|
|
283
|
+
options
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# PostgreSQL reports an array column as its element type plus a separate
|
|
287
|
+
# array flag, so the "[]" has to be put back before the type can be used in
|
|
288
|
+
# DDL again.
|
|
289
|
+
def sql_type_for(column)
|
|
290
|
+
type = column.sql_type
|
|
291
|
+
type = "#{type}[]" if column.respond_to?(:array?) && column.array?
|
|
292
|
+
type
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def normalize_type(sql_type)
|
|
296
|
+
sql_type.to_s.downcase.gsub(/\s+/, ' ').strip
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def archived_at_column
|
|
300
|
+
ColdStorage.config.archived_at_column
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def index_name(table, columns, suffix)
|
|
304
|
+
name = "index_#{table}_on_#{columns.join('_and_')}"
|
|
305
|
+
name = "idx_ra_#{Digest::MD5.hexdigest(name)[0, 20]}_#{suffix}" if name.length > 63
|
|
306
|
+
name
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def record(type, table, name, detail)
|
|
310
|
+
change = Change.new(type: type, table: table, name: name, detail: detail)
|
|
311
|
+
changes << change
|
|
312
|
+
log(change.to_s)
|
|
313
|
+
change
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def record_schema_version
|
|
317
|
+
version = source_schema_version
|
|
318
|
+
Metadata.schema_version = version if version
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def source_schema_version
|
|
322
|
+
model = source_models.first || ActiveRecord::Base
|
|
323
|
+
model.connection_pool.migration_context.current_version
|
|
324
|
+
rescue StandardError
|
|
325
|
+
nil
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def log_summary(apply)
|
|
329
|
+
verb = apply ? 'applied' : 'pending'
|
|
330
|
+
if changes.empty?
|
|
331
|
+
log("archive schema is up to date (#{tables.size} tables)")
|
|
332
|
+
else
|
|
333
|
+
log("#{changes.size} schema change(s) #{verb} across #{tables.size} table(s)")
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|