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,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
|