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.
@@ -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