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,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'monitor'
4
+
5
+ module ColdStorage
6
+ # Namespace holding the generated archive classes, so they have real names in
7
+ # logs and backtraces (ColdStorage::Archived::Invoices).
8
+ #
9
+ # Classes appear here on demand: an association declared on one archive model
10
+ # names its counterpart, and the constant is built the first time it is
11
+ # touched.
12
+ module Archived
13
+ def self.const_missing(name)
14
+ source = ArchiveModel.registered_source(name)
15
+ return super if source.nil?
16
+
17
+ ArchiveModel.for(source)
18
+ end
19
+ end
20
+
21
+ # Builds, and caches, the ActiveRecord class that reads and writes a mirrored
22
+ # table in the archive database.
23
+ module ArchiveModel
24
+ MIRRORED_MACROS = %i[has_many has_one belongs_to].freeze
25
+ MONITOR = Monitor.new
26
+ private_constant :MONITOR
27
+
28
+ class << self
29
+ # @param source [Class] an ActiveRecord model in the primary database
30
+ # @return [Class] its counterpart in the archive database
31
+ def for(source)
32
+ ArchiveRecord.connect!
33
+ cache[source.table_name] || MONITOR.synchronize { cache[source.table_name] ||= build(source) }
34
+ end
35
+
36
+ # @api private the source model behind a generated constant name
37
+ def registered_source(const_name)
38
+ MONITOR.synchronize { sources[const_name.to_s] }
39
+ end
40
+
41
+ def clear!
42
+ MONITOR.synchronize do
43
+ @cache = {}
44
+ @sources = {}
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def cache
51
+ @cache ||= {}
52
+ end
53
+
54
+ # Constant name => source model, so Archived.const_missing can build a
55
+ # class an association asked for.
56
+ def sources
57
+ @sources ||= {}
58
+ end
59
+
60
+ def build(source)
61
+ table = source.table_name
62
+ polymorphic = source.polymorphic_name
63
+
64
+ klass = Class.new(ArchiveRecord) do
65
+ self.table_name = table
66
+ self.inheritance_column = nil
67
+ self.record_timestamps = false
68
+ end
69
+ klass.primary_key = source.primary_key
70
+ klass.source_model_name = source.name
71
+ # So `has_many ..., as:` conditions keep matching the archived rows,
72
+ # which store the *source* class name in their type column.
73
+ klass.define_singleton_method(:polymorphic_name) { polymorphic }
74
+
75
+ register(source)
76
+ const = const_name_for(table)
77
+ Archived.send(:remove_const, const) if Archived.const_defined?(const, false)
78
+ Archived.const_set(const, klass)
79
+ mirror_associations(klass, source)
80
+ klass
81
+ end
82
+
83
+ def register(source)
84
+ sources[const_name_for(source.table_name)] = source
85
+ end
86
+
87
+ # Redeclares the source associations against the archive classes, so an
88
+ # archived row can be read together with its archived children.
89
+ #
90
+ # Deliberately dropped: dependent:, counter caches, touch and autosave -
91
+ # an archive is a reading surface, it must not cascade anything.
92
+ def mirror_associations(klass, source)
93
+ source.reflect_on_all_associations.each do |reflection|
94
+ next unless MIRRORED_MACROS.include?(reflection.macro)
95
+ next if reflection.options[:polymorphic]
96
+
97
+ if reflection.options[:through]
98
+ mirror_through_association(klass, reflection)
99
+ else
100
+ mirror_association(klass, reflection)
101
+ end
102
+ end
103
+ end
104
+
105
+ # A through association needs no class_name: it composes out of the two
106
+ # associations it is built on, which are mirrored too, so it stays inside
107
+ # the archive database.
108
+ def mirror_through_association(klass, reflection)
109
+ # A polymorphic source would resolve to a primary-database class and
110
+ # quietly read from the wrong database.
111
+ return if reflection.options[:source_type]
112
+
113
+ options = { through: reflection.options[:through] }
114
+ options[:source] = reflection.options[:source] if reflection.options[:source]
115
+
116
+ klass.public_send(reflection.macro, reflection.name, reflection.scope, **options)
117
+ rescue StandardError => e
118
+ log_skipped(klass, reflection, e)
119
+ end
120
+
121
+ def mirror_association(klass, reflection)
122
+ target = reflection.klass
123
+ return if target.abstract_class? || target.table_name.blank?
124
+
125
+ register(target)
126
+ klass.public_send(reflection.macro, reflection.name, portable_scope(reflection),
127
+ **options_for(reflection, target))
128
+ rescue StandardError => e
129
+ log_skipped(klass, reflection, e)
130
+ end
131
+
132
+ # An association scope is written against the source model, and may call
133
+ # scopes or class methods only that model has:
134
+ #
135
+ # has_many :time_periods, -> { sorted('start_time', 'asc') }
136
+ #
137
+ # Plain conditions carry over to the archive; anything the archive class
138
+ # cannot evaluate falls back to the unfiltered relation rather than
139
+ # blowing up a read.
140
+ def portable_scope(reflection)
141
+ scope = reflection.scope
142
+ return nil if scope.nil?
143
+
144
+ lambda do |*args|
145
+ instance_exec(*args, &scope)
146
+ rescue StandardError => e
147
+ ColdStorage.logger.debug do
148
+ "#{Logging::PREFIX} ignoring the scope of #{reflection.name} on the archive model: " \
149
+ "#{e.class}: #{e.message}"
150
+ end
151
+ self
152
+ end
153
+ end
154
+
155
+ def log_skipped(klass, reflection, error)
156
+ ColdStorage.logger.debug do
157
+ "#{Logging::PREFIX} skipping association #{klass.source_model_name}##{reflection.name} " \
158
+ "on the archive model: #{error.class}: #{error.message}"
159
+ end
160
+ end
161
+
162
+ def options_for(reflection, target)
163
+ options = {
164
+ class_name: "ColdStorage::Archived::#{const_name_for(target.table_name)}",
165
+ foreign_key: reflection.foreign_key,
166
+ inverse_of: false
167
+ }
168
+ options[:primary_key] = reflection.options[:primary_key] if reflection.options[:primary_key]
169
+ options[:as] = reflection.options[:as] if reflection.options[:as]
170
+ options[:optional] = true if reflection.macro == :belongs_to
171
+ options
172
+ end
173
+
174
+ def const_name_for(table)
175
+ name = table.gsub(/[^A-Za-z0-9_]/, '_').camelize
176
+ name = "T#{name}" unless name.match?(/\A[A-Z]/)
177
+ name
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ColdStorage
4
+ # Abstract base of everything that lives in the archive database.
5
+ #
6
+ # The connection is established lazily, on first use, so an application that
7
+ # has not configured the archive database yet still boots.
8
+ class ArchiveRecord < ActiveRecord::Base
9
+ self.abstract_class = true
10
+
11
+ # Name of the model in the primary database this table belongs to.
12
+ class_attribute :source_model_name, instance_accessor: false, default: nil
13
+
14
+ # The model in the primary database an archived row came from.
15
+ def self.source_model
16
+ source_model_name&.constantize
17
+ end
18
+
19
+ def source_model
20
+ self.class.source_model
21
+ end
22
+
23
+ # Moves this row (and, with `with:`, its archived children) back into the
24
+ # primary database.
25
+ #
26
+ # Invoice.archived.find(42).restore!(with: :all)
27
+ #
28
+ # @return [Integer] number of restored rows
29
+ def restore!(**options)
30
+ model = source_model
31
+ raise NotArchivableError, "#{self.class} has no source model" if model.nil?
32
+
33
+ ColdStorage.restore(model, [id], **options)
34
+ end
35
+
36
+ class << self
37
+ # @return [self]
38
+ def connect!
39
+ return self if @connected
40
+
41
+ MUTEX.synchronize do
42
+ next if @connected
43
+
44
+ database = ColdStorage.config.archive_database.to_sym
45
+ assert_configured!(database)
46
+ connects_to database: { writing: database, reading: database }
47
+ @connected = true
48
+ end
49
+
50
+ self
51
+ end
52
+
53
+ # Yields a checked out connection to the archive database.
54
+ # (Named differently from ActiveRecord's own `with_connection` so that
55
+ # nothing in ActiveRecord ends up calling this override.)
56
+ def with_archive_connection(&block)
57
+ connect!
58
+ connection_pool.with_connection(&block)
59
+ end
60
+
61
+ # Forget the established connection, e.g. after changing the config in a
62
+ # test. The pool itself is left to ActiveRecord.
63
+ def reset_connection!
64
+ MUTEX.synchronize { @connected = false }
65
+ end
66
+
67
+ def database_config
68
+ env = current_env
69
+ ActiveRecord::Base.configurations.configs_for(
70
+ env_name: env,
71
+ name: ColdStorage.config.archive_database.to_s,
72
+ include_hidden: true
73
+ )
74
+ end
75
+
76
+ private
77
+
78
+ MUTEX = Mutex.new
79
+ private_constant :MUTEX
80
+
81
+ def current_env
82
+ if defined?(Rails) && Rails.respond_to?(:env)
83
+ Rails.env.to_s
84
+ else
85
+ ENV['RAILS_ENV'].presence || ENV['RACK_ENV'].presence || 'default_env'
86
+ end
87
+ end
88
+
89
+ def assert_configured!(database)
90
+ return if database_config
91
+
92
+ available = ActiveRecord::Base.configurations
93
+ .configs_for(env_name: current_env, include_hidden: true)
94
+ .map(&:name)
95
+ raise ConfigurationError, <<~MESSAGE
96
+ No "#{database}" database configured for the "#{current_env}" environment.
97
+ Available entries: #{available.join(', ')}.
98
+
99
+ Add one to config/database.yml, for example:
100
+
101
+ #{current_env}:
102
+ primary:
103
+ <<: *default
104
+ #{database}:
105
+ <<: *default
106
+ database: <%= ENV.fetch('ARCHIVE_POSTGRES_DB') %>
107
+ database_tasks: false
108
+
109
+ `database_tasks: false` keeps `rails db:migrate` from running the
110
+ application migrations against the archive database; ColdStorage
111
+ mirrors the schema itself.
112
+ MESSAGE
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ColdStorage
4
+ # Moves rows of one model from the primary database to the archive database.
5
+ #
6
+ # The two databases cannot share a transaction, so every batch is:
7
+ # 1. read from the primary database,
8
+ # 2. upserted into the archive database (idempotent, keyed on the primary
9
+ # key, so a retry can never duplicate a row),
10
+ # 3. deleted from the primary database.
11
+ #
12
+ # A crash between 2 and 3 leaves the row in both databases; the next run
13
+ # upserts it again and deletes it. Archiving is therefore at-least-once, and
14
+ # never loses a row.
15
+ class Archiver
16
+ include Logging
17
+ include RowReader
18
+
19
+ # Outcome of one call.
20
+ Result = Struct.new(:model_name, :archived, :deleted, :skipped, :reason, :dry_run, keyword_init: true) do
21
+ def skipped?
22
+ skipped
23
+ end
24
+
25
+ def to_s
26
+ return "#{model_name}: skipped (#{reason})" if skipped?
27
+
28
+ "#{model_name}: archived #{archived}, deleted #{deleted}#{' [dry run]' if dry_run}"
29
+ end
30
+ end
31
+
32
+ attr_reader :model, :policy, :relation, :result
33
+
34
+ # @param model [Class] the ActiveRecord model to archive
35
+ # @param relation [ActiveRecord::Relation, nil] rows to archive, defaults to
36
+ # what the model's policy selects
37
+ # @param force [Boolean] ignore the `every:` window
38
+ # @param limit [Integer, nil] stop after this many rows
39
+ # @param dry_run [Boolean] report what would move without moving it
40
+ # @param track [Boolean] write a cold_storage_runs row
41
+ # @param cascade [Boolean, Symbol, Array, Hash] true to follow the policy's
42
+ # cascade, false for none, or an explicit association tree
43
+ # @param delete_after_archive [Boolean, nil] override the policy's setting
44
+ def initialize(model, relation: nil, force: false, limit: nil,
45
+ dry_run: ColdStorage.config.dry_run, batch_size: nil, track: true,
46
+ cascade: true, delete_after_archive: nil)
47
+ @model = model
48
+ @policy = model.respond_to?(:archiving_policy) ? model.archiving_policy : nil
49
+ @relation = relation
50
+ @force = force
51
+ @limit = limit
52
+ @dry_run = dry_run
53
+ @batch_size = batch_size
54
+ @track = track && relation.nil?
55
+ @cascade = cascade
56
+ @delete_after_archive = delete_after_archive
57
+
58
+ return if @policy || @relation
59
+
60
+ raise NotArchivableError,
61
+ "#{model} is not archivable. Add `archivable ...` to the model, or pass an explicit relation."
62
+ end
63
+
64
+ # @return [Result]
65
+ def call
66
+ return skipped(:disabled) unless ColdStorage.config.enabled
67
+ return skipped(:destroy_only) if sweep_less?
68
+ return skipped(:not_due) unless due?
69
+
70
+ policy&.validate_against_schema!(model)
71
+ ensure_archive_table!
72
+
73
+ run = start_run
74
+ archived = 0
75
+ deleted = 0
76
+
77
+ each_batch do |rows, ids|
78
+ archived += copy(rows)
79
+ deleted += cascade_and_delete(ids)
80
+ break if limit_reached?(archived)
81
+
82
+ throttle
83
+ end
84
+
85
+ run&.succeed!(archived_count: archived, deleted_count: deleted)
86
+ finish(archived, deleted)
87
+ rescue StandardError => e
88
+ run&.fail!(e, archived_count: archived.to_i, deleted_count: deleted.to_i)
89
+ raise
90
+ end
91
+
92
+ private
93
+
94
+ def due?
95
+ return true if @force || policy.nil?
96
+
97
+ policy.due?(Run.last_success_at(model))
98
+ end
99
+
100
+ # `archivable on_destroy: true` without an age or scope has nothing for a
101
+ # scheduled run to sweep: it only reacts to deletes.
102
+ def sweep_less?
103
+ @relation.nil? && policy && !policy.sweeps?
104
+ end
105
+
106
+ def batch_size
107
+ @batch_size || policy&.batch_size_value || ColdStorage.config.batch_size
108
+ end
109
+
110
+ def delete_after_archive?
111
+ return @delete_after_archive unless @delete_after_archive.nil?
112
+
113
+ policy.nil? ? ColdStorage.config.delete_after_archive : policy.delete_after_archive?
114
+ end
115
+
116
+ def delete_method
117
+ policy&.delete_method_value || ColdStorage.config.delete_method
118
+ end
119
+
120
+ def primary_key
121
+ @primary_key ||= model.primary_key
122
+ end
123
+
124
+ def scope
125
+ @relation || policy.relation(model)
126
+ end
127
+
128
+ def archive_model
129
+ @archive_model ||= ArchiveModel.for(model)
130
+ end
131
+
132
+ # Walks the archivable rows in primary key order.
133
+ #
134
+ # Reading raw rows (rather than model instances) keeps the values in their
135
+ # database representation, so model level serializers, default scopes and
136
+ # callbacks cannot change what gets archived.
137
+ def each_batch
138
+ cursor = nil
139
+ moved = 0
140
+
141
+ loop do
142
+ batch = scope.reorder(primary_key => :asc).limit(remaining(moved) || batch_size)
143
+ batch = batch.where(model.arel_table[primary_key].gt(cursor)) if cursor
144
+
145
+ rows = read(batch)
146
+ break if rows.empty?
147
+
148
+ ids = rows.map { |row| row[primary_key] }
149
+ cursor = ids.last
150
+ moved += rows.size
151
+
152
+ yield(rows, ids)
153
+
154
+ break if rows.size < batch_size
155
+ end
156
+ end
157
+
158
+ def remaining(moved)
159
+ return nil unless @limit
160
+
161
+ [@limit - moved, 0].max.then { |left| left.zero? ? nil : [left, batch_size].min }
162
+ end
163
+
164
+ def read(batch)
165
+ sql = batch.select(model.arel_table[Arel.star]).to_sql
166
+ model.connection_pool.with_connection do |connection|
167
+ select_rows_as_hashes(connection, sql, "#{model} Archive Load")
168
+ end
169
+ end
170
+
171
+ def copy(rows)
172
+ return rows.size if @dry_run
173
+
174
+ now = Time.current
175
+ payload = rows.map do |row|
176
+ stamped = row.dup
177
+ stamped[archived_at_column.to_s] = now if archived_at_column
178
+ stamped
179
+ end
180
+
181
+ archive_model.unscoped.upsert_all(
182
+ payload,
183
+ unique_by: primary_key.to_sym,
184
+ record_timestamps: false,
185
+ returning: false
186
+ )
187
+ rows.size
188
+ end
189
+
190
+ def cascade_and_delete(ids)
191
+ archive_children(ids)
192
+ return 0 unless delete_after_archive?
193
+ return ids.size if @dry_run
194
+
195
+ target = model.unscoped.where(primary_key => ids)
196
+ model.transaction { target.public_send(delete_method) }
197
+ ids.size
198
+ end
199
+
200
+ # The associations to take along: the policy's cascade by default, or the
201
+ # explicit tree the caller passed.
202
+ def cascade_tree
203
+ return {} if @cascade == false || @cascade.nil?
204
+ return policy&.cascade || {} if @cascade == true
205
+
206
+ AssociationTree.normalize(@cascade)
207
+ end
208
+
209
+ # Children are archived (and deleted) before their parents, so foreign keys
210
+ # in the primary database stay satisfied at every step.
211
+ #
212
+ # A parent that keeps its rows keeps its children's too: `delete_after_archive`
213
+ # travels down the tree.
214
+ def archive_children(parent_ids)
215
+ cascade_tree.each do |association, nested|
216
+ reflection = Policy.cascade_reflection!(model, association)
217
+
218
+ self.class.new(
219
+ reflection.klass,
220
+ relation: child_relation(reflection, parent_ids),
221
+ force: true,
222
+ dry_run: @dry_run,
223
+ batch_size: batch_size,
224
+ track: false,
225
+ cascade: nested.nil? ? true : nested,
226
+ delete_after_archive: @delete_after_archive
227
+ ).call
228
+ end
229
+ end
230
+
231
+ def child_relation(reflection, parent_ids)
232
+ scope = reflection.klass.unscoped.where(reflection.foreign_key => parent_ids)
233
+ scope = scope.where(reflection.type => model.polymorphic_name) if reflection.type
234
+ scope
235
+ end
236
+
237
+ def ensure_archive_table!
238
+ table = model.table_name
239
+ exists = ArchiveRecord.with_archive_connection { |connection| connection.table_exists?(table) }
240
+ return if exists
241
+
242
+ raise SchemaMissingError,
243
+ "The archive database has no #{table} table yet. " \
244
+ 'Run `rails cold_storage:schema:sync` (or ColdStorage.sync_schema!) first.'
245
+ end
246
+
247
+ def archived_at_column
248
+ column = ColdStorage.config.archived_at_column
249
+ return nil if column.nil?
250
+ return nil if model.column_names.include?(column.to_s)
251
+
252
+ column
253
+ end
254
+
255
+ def start_run
256
+ return nil if @dry_run || !@track
257
+
258
+ Run.start!(model)
259
+ end
260
+
261
+ def limit_reached?(archived)
262
+ @limit && archived >= @limit
263
+ end
264
+
265
+ def throttle
266
+ sleep(ColdStorage.config.throttle) if ColdStorage.config.throttle.to_f.positive?
267
+ end
268
+
269
+ def skipped(reason)
270
+ result = Result.new(model_name: model.to_s, archived: 0, deleted: 0, skipped: true,
271
+ reason: reason, dry_run: @dry_run)
272
+ log(result.to_s)
273
+ result
274
+ end
275
+
276
+ def finish(archived, deleted)
277
+ result = Result.new(model_name: model.to_s, archived: archived, deleted: deleted,
278
+ skipped: false, reason: nil, dry_run: @dry_run)
279
+ log(result.to_s)
280
+ result
281
+ end
282
+ end
283
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ColdStorage
4
+ # Normalizes the association trees used by `cascade:` and `with:` into a
5
+ # plain Hash of name => nested tree.
6
+ #
7
+ # :lines => { lines: nil }
8
+ # [:lines, :notes] => { lines: nil, notes: nil }
9
+ # [{ lines: [:taxes] }, :notes] => { lines: [:taxes], notes: nil }
10
+ #
11
+ # Nested values are left as given; each level normalizes its own.
12
+ module AssociationTree
13
+ module_function
14
+
15
+ def normalize(value)
16
+ case value
17
+ when nil, false then {}
18
+ when Symbol, String then { value.to_sym => nil }
19
+ when Array then value.inject({}) { |tree, item| tree.merge(normalize(item)) }
20
+ when Hash then value.to_h { |name, nested| [name.to_sym, nested] }
21
+ else
22
+ raise ArgumentError, "expected an association name, array or hash, got #{value.inspect}"
23
+ end
24
+ end
25
+ end
26
+ end