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,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ColdStorage
|
|
4
|
+
# Global defaults. Every model can override most of these through its own
|
|
5
|
+
# `archivable` options.
|
|
6
|
+
class Configuration
|
|
7
|
+
TYPE_MISMATCH_STRATEGIES = %i[warn raise change ignore].freeze
|
|
8
|
+
|
|
9
|
+
# Master switch. When false, nothing is copied or deleted: scheduled runs
|
|
10
|
+
# report themselves as skipped and the on_destroy hook stands down. Handy
|
|
11
|
+
# for the test environment, which has no archive database.
|
|
12
|
+
# Schema tasks keep working, so `schema:check` can still run in CI.
|
|
13
|
+
attr_accessor :enabled
|
|
14
|
+
# Name of the database.yml entry holding the archive database.
|
|
15
|
+
attr_accessor :archive_database
|
|
16
|
+
# Rows copied (and deleted) per round trip.
|
|
17
|
+
attr_accessor :batch_size
|
|
18
|
+
# Default column used to decide how old a record is.
|
|
19
|
+
attr_accessor :timestamp_column
|
|
20
|
+
# Default column used by `deleted: true`.
|
|
21
|
+
attr_accessor :deleted_column
|
|
22
|
+
# Extra column added to every archive table, holding the archiving time.
|
|
23
|
+
# Set to nil to disable.
|
|
24
|
+
attr_accessor :archived_at_column
|
|
25
|
+
# Delete rows from the primary database once they are safely copied.
|
|
26
|
+
attr_accessor :delete_after_archive
|
|
27
|
+
# :delete_all (fast, no callbacks) or :destroy_all (slow, runs callbacks).
|
|
28
|
+
attr_accessor :delete_method
|
|
29
|
+
# Copy indexes to the archive tables.
|
|
30
|
+
attr_accessor :mirror_indexes
|
|
31
|
+
# Copy NOT NULL constraints. Off by default: an archive table should never
|
|
32
|
+
# reject historical rows because the primary schema grew stricter later.
|
|
33
|
+
attr_accessor :mirror_null_constraints
|
|
34
|
+
# Copy column defaults. Off by default, rows are always inserted complete.
|
|
35
|
+
attr_accessor :mirror_defaults
|
|
36
|
+
# Drop archive columns that no longer exist in the primary database. Off by
|
|
37
|
+
# default so that already archived data keeps its columns.
|
|
38
|
+
attr_accessor :drop_removed_columns
|
|
39
|
+
# What to do when a column type differs: :warn, :raise, :change or :ignore.
|
|
40
|
+
attr_reader :on_type_mismatch
|
|
41
|
+
# What to do when `on_destroy:` cannot reach the archive database:
|
|
42
|
+
# :raise (block the delete, keep the data) or :log (let the delete through).
|
|
43
|
+
attr_reader :on_destroy_error
|
|
44
|
+
# Run the schema mirror automatically after db:migrate / db:schema:load.
|
|
45
|
+
attr_accessor :sync_schema_after_migrate
|
|
46
|
+
# Seconds to sleep between batches, to keep long runs off the hot path.
|
|
47
|
+
attr_accessor :throttle
|
|
48
|
+
# ActiveJob queue used by the bundled jobs.
|
|
49
|
+
attr_accessor :job_queue
|
|
50
|
+
# Parent class of the bundled jobs, as a string.
|
|
51
|
+
attr_accessor :job_parent_class
|
|
52
|
+
# Log every statement/plan the gem produces without touching any data.
|
|
53
|
+
attr_accessor :dry_run
|
|
54
|
+
attr_accessor :logger
|
|
55
|
+
|
|
56
|
+
def initialize
|
|
57
|
+
@enabled = true
|
|
58
|
+
@archive_database = :archive
|
|
59
|
+
@batch_size = 1_000
|
|
60
|
+
@timestamp_column = :created_at
|
|
61
|
+
@deleted_column = :deleted_at
|
|
62
|
+
@archived_at_column = :archived_at
|
|
63
|
+
@delete_after_archive = true
|
|
64
|
+
@delete_method = :delete_all
|
|
65
|
+
@mirror_indexes = true
|
|
66
|
+
@mirror_null_constraints = false
|
|
67
|
+
@mirror_defaults = false
|
|
68
|
+
@drop_removed_columns = false
|
|
69
|
+
@on_type_mismatch = :warn
|
|
70
|
+
@on_destroy_error = :raise
|
|
71
|
+
@sync_schema_after_migrate = true
|
|
72
|
+
@throttle = 0
|
|
73
|
+
@job_queue = :default
|
|
74
|
+
@job_parent_class = 'ActiveJob::Base'
|
|
75
|
+
@dry_run = false
|
|
76
|
+
@logger = nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def on_type_mismatch=(strategy)
|
|
80
|
+
strategy = strategy.to_sym
|
|
81
|
+
unless TYPE_MISMATCH_STRATEGIES.include?(strategy)
|
|
82
|
+
raise ConfigurationError,
|
|
83
|
+
"on_type_mismatch must be one of #{TYPE_MISMATCH_STRATEGIES.join(', ')}, got #{strategy.inspect}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
@on_type_mismatch = strategy
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def on_destroy_error=(strategy)
|
|
90
|
+
strategy = strategy.to_sym
|
|
91
|
+
raise ConfigurationError, 'on_destroy_error must be :raise or :log' unless %i[raise log].include?(strategy)
|
|
92
|
+
|
|
93
|
+
@on_destroy_error = strategy
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def delete_method=(method)
|
|
97
|
+
method = method.to_sym
|
|
98
|
+
unless %i[delete_all destroy_all].include?(method)
|
|
99
|
+
raise ConfigurationError, "delete_method must be :delete_all or :destroy_all, got #{method.inspect}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
@delete_method = method
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ColdStorage
|
|
4
|
+
# The gem's own tables inside the archive database. They are created on
|
|
5
|
+
# demand: the archive database needs no migrations of its own.
|
|
6
|
+
module InternalSchema
|
|
7
|
+
RUNS_TABLE = 'cold_storage_runs'
|
|
8
|
+
METADATA_TABLE = 'cold_storage_metadata'
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def ensure!
|
|
12
|
+
ArchiveRecord.with_archive_connection do |connection|
|
|
13
|
+
create_runs_table(connection)
|
|
14
|
+
create_metadata_table(connection)
|
|
15
|
+
end
|
|
16
|
+
true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def ready?
|
|
20
|
+
ArchiveRecord.with_archive_connection do |connection|
|
|
21
|
+
connection.table_exists?(RUNS_TABLE) && connection.table_exists?(METADATA_TABLE)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def create_runs_table(connection)
|
|
28
|
+
return if connection.table_exists?(RUNS_TABLE)
|
|
29
|
+
|
|
30
|
+
connection.create_table(RUNS_TABLE) do |t|
|
|
31
|
+
t.string :archived_model, null: false
|
|
32
|
+
t.string :archived_table
|
|
33
|
+
t.string :status, null: false, default: 'running'
|
|
34
|
+
t.integer :archived_count, null: false, default: 0
|
|
35
|
+
t.integer :deleted_count, null: false, default: 0
|
|
36
|
+
t.datetime :started_at
|
|
37
|
+
t.datetime :finished_at
|
|
38
|
+
t.text :error_message
|
|
39
|
+
t.timestamps
|
|
40
|
+
end
|
|
41
|
+
connection.add_index(RUNS_TABLE, %i[archived_model status finished_at],
|
|
42
|
+
name: 'index_cold_storage_runs_on_model_and_status')
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def create_metadata_table(connection)
|
|
46
|
+
return if connection.table_exists?(METADATA_TABLE)
|
|
47
|
+
|
|
48
|
+
connection.create_table(METADATA_TABLE, id: false) do |t|
|
|
49
|
+
t.string :key, null: false, primary_key: true
|
|
50
|
+
t.text :value
|
|
51
|
+
t.datetime :updated_at
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ColdStorage
|
|
4
|
+
# ActiveJob entry points.
|
|
5
|
+
#
|
|
6
|
+
# The classes are built on first use (or when ActiveJob loads) so that the
|
|
7
|
+
# gem does not force ActiveJob on applications that do not use it, and so
|
|
8
|
+
# that `config.job_parent_class` is honoured.
|
|
9
|
+
#
|
|
10
|
+
# ColdStorage::ArchiveModelJob.perform_later('Payroll')
|
|
11
|
+
# ColdStorage::ArchiveAllJob.perform_later
|
|
12
|
+
module Jobs
|
|
13
|
+
JOBS = %i[ArchiveModelJob ArchiveAllJob].freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
# @return [Boolean] whether the job classes exist now
|
|
17
|
+
def define!
|
|
18
|
+
return false unless defined?(ActiveJob::Base)
|
|
19
|
+
return true if ColdStorage.const_defined?(:ArchiveModelJob, false)
|
|
20
|
+
|
|
21
|
+
parent = ColdStorage.config.job_parent_class.constantize
|
|
22
|
+
ColdStorage.const_set(:ArchiveModelJob, build_model_job(parent))
|
|
23
|
+
ColdStorage.const_set(:ArchiveAllJob, build_all_job(parent))
|
|
24
|
+
true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# Archives one model.
|
|
30
|
+
def build_model_job(parent)
|
|
31
|
+
Class.new(parent) do
|
|
32
|
+
queue_as { ColdStorage.config.job_queue }
|
|
33
|
+
|
|
34
|
+
def perform(model_name, **options)
|
|
35
|
+
ColdStorage.archive(model_name.to_s.constantize, **options.symbolize_keys)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Fans out over every archivable model. Models whose `every:` window has
|
|
41
|
+
# not elapsed skip themselves, so this is safe to schedule daily.
|
|
42
|
+
def build_all_job(parent)
|
|
43
|
+
Class.new(parent) do
|
|
44
|
+
queue_as { ColdStorage.config.job_queue }
|
|
45
|
+
|
|
46
|
+
def perform(**options)
|
|
47
|
+
ColdStorage.models.each do |model|
|
|
48
|
+
ColdStorage::ArchiveModelJob.perform_later(model.name, **options.symbolize_keys)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.const_missing(name)
|
|
57
|
+
return super unless Jobs::JOBS.include?(name) && Jobs.define!
|
|
58
|
+
|
|
59
|
+
const_get(name)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ColdStorage
|
|
4
|
+
# Shared, prefixed logging.
|
|
5
|
+
module Logging
|
|
6
|
+
PREFIX = '[ColdStorage]'
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def log(message, level: :info)
|
|
11
|
+
ColdStorage.logger.public_send(level) { "#{PREFIX} #{message}" }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def warn_log(message)
|
|
15
|
+
log(message, level: :warn)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ColdStorage
|
|
4
|
+
# Key/value store in the archive database. Holds the migration version the
|
|
5
|
+
# mirrored schema was last synced against.
|
|
6
|
+
class Metadata < ArchiveRecord
|
|
7
|
+
self.table_name = InternalSchema::METADATA_TABLE
|
|
8
|
+
self.primary_key = 'key'
|
|
9
|
+
|
|
10
|
+
SCHEMA_VERSION_KEY = 'schema_version'
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def get(key)
|
|
14
|
+
InternalSchema.ensure!
|
|
15
|
+
where(key: key.to_s).pick(:value)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def set(key, value)
|
|
19
|
+
InternalSchema.ensure!
|
|
20
|
+
upsert({ key: key.to_s, value: value.to_s, updated_at: Time.current }, unique_by: :key)
|
|
21
|
+
value
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def schema_version
|
|
25
|
+
get(SCHEMA_VERSION_KEY)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def schema_version=(version)
|
|
29
|
+
set(SCHEMA_VERSION_KEY, version)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ColdStorage
|
|
4
|
+
# Extended onto ActiveRecord::Base, so every model can declare itself
|
|
5
|
+
# archivable.
|
|
6
|
+
module Model
|
|
7
|
+
# Declares which rows of this model move to the archive database.
|
|
8
|
+
#
|
|
9
|
+
# class Payroll < ApplicationRecord
|
|
10
|
+
# archivable after: 18.months, every: 1.month
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# Options:
|
|
14
|
+
# after: Duration. Rows older than this are archivable.
|
|
15
|
+
# (alias: older_than:)
|
|
16
|
+
# on: Column the age is measured on.
|
|
17
|
+
# Default: :created_at, or the deleted column when
|
|
18
|
+
# deleted: true.
|
|
19
|
+
# deleted: true to archive soft-deleted rows only.
|
|
20
|
+
# deleted_column: Column holding the soft-delete timestamp.
|
|
21
|
+
# Default: ColdStorage.config.deleted_column.
|
|
22
|
+
# on_destroy: true to copy a row to the archive whenever it is
|
|
23
|
+
# really deleted (destroy/destroy_all), so hard
|
|
24
|
+
# deletes are kept too. (alias: hard_delete:)
|
|
25
|
+
# every: Duration. Minimum time between two runs.
|
|
26
|
+
# scope: Symbol, proc or relation narrowing the selection.
|
|
27
|
+
# cascade: has_many/has_one association names to archive
|
|
28
|
+
# together with their parent.
|
|
29
|
+
# batch_size: Rows moved per round trip.
|
|
30
|
+
# delete_after_archive: false to copy without deleting the source rows.
|
|
31
|
+
# delete_method: :delete_all (default) or :destroy_all.
|
|
32
|
+
#
|
|
33
|
+
# @return [ColdStorage::Policy]
|
|
34
|
+
def archivable(**options)
|
|
35
|
+
include ColdStorage::Archivable unless include?(ColdStorage::Archivable)
|
|
36
|
+
|
|
37
|
+
self.archiving_policy = Policy.new(self, **options)
|
|
38
|
+
install_archive_on_destroy! if archiving_policy.on_destroy?
|
|
39
|
+
Registry.register(self)
|
|
40
|
+
archiving_policy
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Every model answers this, so callers never have to rescue NoMethodError.
|
|
44
|
+
def archivable?
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ColdStorage
|
|
4
|
+
# The `archivable` options of a single model, and the relation they describe.
|
|
5
|
+
#
|
|
6
|
+
# archivable after: 18.months # older than 18 months
|
|
7
|
+
# archivable after: 30.days, on: :closed_at # ... measured on another column
|
|
8
|
+
# archivable deleted: true # soft-deleted rows only
|
|
9
|
+
# archivable deleted: true, after: 90.days # soft-deleted 90+ days ago
|
|
10
|
+
# archivable after: 1.year, every: 1.month # ... and only run monthly
|
|
11
|
+
# archivable after: 2.years, cascade: [:items] # take the children along
|
|
12
|
+
# archivable after: 2.years, cascade: { items: [:taxes] } # ... and theirs
|
|
13
|
+
# archivable on_destroy: true # catch hard deletes
|
|
14
|
+
class Policy
|
|
15
|
+
OPTIONS = %i[
|
|
16
|
+
after older_than on deleted deleted_column every scope
|
|
17
|
+
batch_size delete_after_archive delete_method cascade
|
|
18
|
+
on_destroy hard_delete
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :model_name, :after, :on, :every, :scope, :batch_size,
|
|
22
|
+
:delete_method, :cascade, :deleted_column
|
|
23
|
+
|
|
24
|
+
def initialize(model, **options)
|
|
25
|
+
unknown = options.keys - OPTIONS
|
|
26
|
+
unless unknown.empty?
|
|
27
|
+
raise InvalidPolicyError,
|
|
28
|
+
"unknown archivable option(s) #{unknown.map(&:inspect).join(', ')} for #{model}. " \
|
|
29
|
+
"Known options: #{OPTIONS.map(&:inspect).join(', ')}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@model_name = model.name
|
|
33
|
+
@after = normalize_duration(options[:after] || options[:older_than])
|
|
34
|
+
@deleted = options.fetch(:deleted, false)
|
|
35
|
+
@deleted_column = (options[:deleted_column] || ColdStorage.config.deleted_column).to_sym
|
|
36
|
+
@on = (options[:on] || default_column).to_sym
|
|
37
|
+
@every = normalize_duration(options[:every])
|
|
38
|
+
@scope = options[:scope]
|
|
39
|
+
@batch_size = options[:batch_size]
|
|
40
|
+
@delete_after_archive = options[:delete_after_archive]
|
|
41
|
+
@delete_method = options[:delete_method]
|
|
42
|
+
@cascade = AssociationTree.normalize(options[:cascade])
|
|
43
|
+
@on_destroy = options.fetch(:on_destroy) { options.fetch(:hard_delete, false) }
|
|
44
|
+
|
|
45
|
+
validate!
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def deleted?
|
|
49
|
+
@deleted
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Copy the row to the archive when it is really destroyed.
|
|
53
|
+
def on_destroy?
|
|
54
|
+
@on_destroy
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Does this policy describe rows a scheduled run should sweep up?
|
|
58
|
+
# `on_destroy: true` on its own does not: it only reacts to deletes.
|
|
59
|
+
def sweeps?
|
|
60
|
+
!after.nil? || deleted? || !scope.nil?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def model
|
|
64
|
+
@model_name.constantize
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def batch_size_value
|
|
68
|
+
batch_size || ColdStorage.config.batch_size
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def delete_after_archive?
|
|
72
|
+
@delete_after_archive.nil? ? ColdStorage.config.delete_after_archive : @delete_after_archive
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def delete_method_value
|
|
76
|
+
delete_method || ColdStorage.config.delete_method
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The rows that are archivable right now.
|
|
80
|
+
#
|
|
81
|
+
# Always starts from `unscoped`: a soft-delete default scope would
|
|
82
|
+
# otherwise hide exactly the rows we are asked to archive.
|
|
83
|
+
#
|
|
84
|
+
# @return [ActiveRecord::Relation]
|
|
85
|
+
def relation(klass = model)
|
|
86
|
+
return klass.none unless sweeps?
|
|
87
|
+
|
|
88
|
+
relation = klass.unscoped
|
|
89
|
+
relation = relation.where.not(deleted_column => nil) if deleted?
|
|
90
|
+
relation = relation.where(klass.arel_table[on].lt(cutoff)) if after
|
|
91
|
+
apply_scope(relation)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @return [Time, nil] rows on the `on:` column older than this are archivable
|
|
95
|
+
def cutoff(now = Time.current)
|
|
96
|
+
return nil unless after
|
|
97
|
+
|
|
98
|
+
now - after
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Has enough time passed since the last successful run?
|
|
102
|
+
def due?(last_run_at)
|
|
103
|
+
return true if every.nil? || last_run_at.nil?
|
|
104
|
+
|
|
105
|
+
last_run_at <= Time.current - every
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Checks the options against the real table. Raises with an actionable
|
|
109
|
+
# message instead of failing later with a SQL error.
|
|
110
|
+
def validate_against_schema!(klass = model)
|
|
111
|
+
columns = klass.column_names
|
|
112
|
+
check_column!(klass, on, columns)
|
|
113
|
+
check_column!(klass, deleted_column, columns) if deleted?
|
|
114
|
+
|
|
115
|
+
if klass.primary_key.nil? || klass.primary_key.is_a?(Array)
|
|
116
|
+
raise InvalidPolicyError,
|
|
117
|
+
"#{klass} must have a single-column primary key to be archivable (got #{klass.primary_key.inspect})"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
validate_cascade!(klass, cascade)
|
|
121
|
+
true
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def cascade_reflection!(klass, name)
|
|
125
|
+
self.class.cascade_reflection!(klass, name)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @return [ActiveRecord::Reflection::AssociationReflection]
|
|
129
|
+
def self.cascade_reflection!(klass, name)
|
|
130
|
+
reflection = klass.reflect_on_association(name)
|
|
131
|
+
raise InvalidPolicyError, "#{klass} has no association #{name.inspect} to cascade to" if reflection.nil?
|
|
132
|
+
|
|
133
|
+
if reflection.through_reflection?
|
|
134
|
+
raise InvalidPolicyError, "cascade does not support :through associations (#{klass}##{name})"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
unless reflection.collection? || reflection.has_one?
|
|
138
|
+
raise InvalidPolicyError,
|
|
139
|
+
"cascade only supports has_many / has_one associations (#{klass}##{name} is a #{reflection.macro})"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
reflection
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @return [String] one-line summary used by the status report
|
|
146
|
+
def to_s
|
|
147
|
+
parts = []
|
|
148
|
+
parts << 'deleted' if deleted?
|
|
149
|
+
parts << "#{on} < #{humanize_duration(after)} ago" if after
|
|
150
|
+
parts << "scope: #{scope.is_a?(Symbol) ? scope : 'custom'}" if scope
|
|
151
|
+
parts << 'on destroy' if on_destroy?
|
|
152
|
+
parts << "every #{humanize_duration(every)}" if every && sweeps?
|
|
153
|
+
parts << "cascade: #{cascade.keys.join(', ')}" if cascade.any?
|
|
154
|
+
parts << 'keeps source rows' unless delete_after_archive?
|
|
155
|
+
parts.join(', ')
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def default_column
|
|
161
|
+
@deleted ? @deleted_column : ColdStorage.config.timestamp_column
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def apply_scope(relation)
|
|
165
|
+
case scope
|
|
166
|
+
when nil then relation
|
|
167
|
+
when Symbol then relation.public_send(scope)
|
|
168
|
+
when Proc then relation.instance_exec(&scope)
|
|
169
|
+
else relation.merge(scope)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def validate!
|
|
174
|
+
return if sweeps? || on_destroy?
|
|
175
|
+
|
|
176
|
+
raise InvalidPolicyError,
|
|
177
|
+
"#{model_name}: archivable needs at least one of after:, deleted:, scope: or on_destroy:. " \
|
|
178
|
+
'Without a criterion every row of the table would be archived.'
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Walks the whole cascade tree, so a typo deep in it is reported before
|
|
182
|
+
# anything moves.
|
|
183
|
+
def validate_cascade!(klass, tree)
|
|
184
|
+
tree.each do |name, nested|
|
|
185
|
+
reflection = self.class.cascade_reflection!(klass, name)
|
|
186
|
+
validate_cascade!(reflection.klass, AssociationTree.normalize(nested))
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def check_column!(klass, column, columns)
|
|
191
|
+
return if columns.include?(column.to_s)
|
|
192
|
+
|
|
193
|
+
raise InvalidPolicyError,
|
|
194
|
+
"#{klass} has no column #{column.inspect} (archivable on:/deleted_column:). " \
|
|
195
|
+
"Available: #{columns.sort.join(', ')}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def normalize_duration(value)
|
|
199
|
+
case value
|
|
200
|
+
when nil then nil
|
|
201
|
+
when ActiveSupport::Duration then value
|
|
202
|
+
when Numeric then value.seconds
|
|
203
|
+
else
|
|
204
|
+
raise InvalidPolicyError, "expected a duration like 6.months, got #{value.inspect}"
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def humanize_duration(duration)
|
|
209
|
+
duration.inspect
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ColdStorage
|
|
4
|
+
# Rails wiring: the `archivable` macro, the logger, the rake tasks, and the
|
|
5
|
+
# automatic schema sync after migrations.
|
|
6
|
+
class Railtie < ::Rails::Railtie
|
|
7
|
+
config.cold_storage = ActiveSupport::OrderedOptions.new
|
|
8
|
+
|
|
9
|
+
initializer 'cold_storage.model' do
|
|
10
|
+
ActiveSupport.on_load(:active_record) do
|
|
11
|
+
extend ColdStorage::Model
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
initializer 'cold_storage.jobs' do
|
|
16
|
+
ActiveSupport.on_load(:active_job) do
|
|
17
|
+
ColdStorage::Jobs.define!
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
initializer 'cold_storage.logger' do |app|
|
|
22
|
+
app.config.after_initialize do
|
|
23
|
+
ColdStorage.config.logger ||= Rails.logger
|
|
24
|
+
app.config.cold_storage.each { |key, value| ColdStorage.config.public_send(:"#{key}=", value) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
rake_tasks do
|
|
29
|
+
load File.expand_path('tasks/cold_storage.rake', __dir__)
|
|
30
|
+
ColdStorage::Railtie.hook_into_migrations!
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# After the primary database changes, bring the archive schema along - for
|
|
34
|
+
# the archivable models only.
|
|
35
|
+
def self.hook_into_migrations!
|
|
36
|
+
%w[db:migrate db:migrate:up db:migrate:down db:rollback db:schema:load].each do |name|
|
|
37
|
+
next unless Rake::Task.task_defined?(name)
|
|
38
|
+
|
|
39
|
+
Rake::Task[name].enhance do
|
|
40
|
+
next unless ColdStorage.config.sync_schema_after_migrate
|
|
41
|
+
|
|
42
|
+
sync = Rake::Task['cold_storage:schema:sync']
|
|
43
|
+
sync.reenable
|
|
44
|
+
sync.invoke
|
|
45
|
+
rescue ColdStorage::ConfigurationError => e
|
|
46
|
+
Rails.logger&.info { "#{ColdStorage::Logging::PREFIX} skipping schema sync: #{e.message}" }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module ColdStorage
|
|
6
|
+
# Keeps track of the models that called `archivable`.
|
|
7
|
+
#
|
|
8
|
+
# Model names are stored as strings, never as class objects, so that code
|
|
9
|
+
# reloading in development does not pin stale constants.
|
|
10
|
+
module Registry
|
|
11
|
+
MUTEX = Mutex.new
|
|
12
|
+
private_constant :MUTEX
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def register(model)
|
|
16
|
+
MUTEX.synchronize { names << model.name } if model.name
|
|
17
|
+
model
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def registered?(model)
|
|
21
|
+
MUTEX.synchronize { names.include?(model.to_s) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Array<String>]
|
|
25
|
+
def model_names(eager_load: true)
|
|
26
|
+
load_application! if eager_load
|
|
27
|
+
MUTEX.synchronize { names.to_a }.sort
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Array<Class>] the archivable models, skipping names that no
|
|
31
|
+
# longer resolve (removed or renamed classes).
|
|
32
|
+
def models(eager_load: true)
|
|
33
|
+
model_names(eager_load: eager_load).filter_map do |name|
|
|
34
|
+
klass = name.safe_constantize
|
|
35
|
+
klass if klass.respond_to?(:archivable?) && klass.archivable?
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def clear!
|
|
40
|
+
MUTEX.synchronize { @names = Set.new }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def names
|
|
46
|
+
@names ||= Set.new
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def load_application!
|
|
50
|
+
return if @eager_loaded
|
|
51
|
+
return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
52
|
+
|
|
53
|
+
Rails.application.eager_load!
|
|
54
|
+
@eager_loaded = true
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
ColdStorage.logger.warn { "#{Logging::PREFIX} eager load failed: #{e.class}: #{e.message}" }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|