archive_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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +348 -0
  3. data/archive_storage.gemspec +42 -0
  4. data/lib/archive_storage/adapters/filesystem.rb +135 -0
  5. data/lib/archive_storage/adapters/memory.rb +101 -0
  6. data/lib/archive_storage/adapters/metadata.rb +23 -0
  7. data/lib/archive_storage/adapters/s3.rb +186 -0
  8. data/lib/archive_storage/configuration.rb +115 -0
  9. data/lib/archive_storage/duration_parser.rb +26 -0
  10. data/lib/archive_storage/enqueuer.rb +29 -0
  11. data/lib/archive_storage/errors.rb +9 -0
  12. data/lib/archive_storage/jobs/migration_job.rb +28 -0
  13. data/lib/archive_storage/jobs/queue_job.rb +65 -0
  14. data/lib/archive_storage/jobs/sidekiq_migration_worker.rb +28 -0
  15. data/lib/archive_storage/jobs/sidekiq_queue_worker.rb +65 -0
  16. data/lib/archive_storage/migration_rate.rb +16 -0
  17. data/lib/archive_storage/migrator.rb +151 -0
  18. data/lib/archive_storage/model.rb +35 -0
  19. data/lib/archive_storage/models/file_record.rb +26 -0
  20. data/lib/archive_storage/mount_config.rb +50 -0
  21. data/lib/archive_storage/plan_result.rb +61 -0
  22. data/lib/archive_storage/planner.rb +190 -0
  23. data/lib/archive_storage/policy.rb +48 -0
  24. data/lib/archive_storage/policy_builder.rb +72 -0
  25. data/lib/archive_storage/railtie.rb +23 -0
  26. data/lib/archive_storage/registry.rb +109 -0
  27. data/lib/archive_storage/schedule_config.rb +79 -0
  28. data/lib/archive_storage/scheduler.rb +93 -0
  29. data/lib/archive_storage/storage.rb +91 -0
  30. data/lib/archive_storage/storage_config.rb +37 -0
  31. data/lib/archive_storage/storage_rule.rb +57 -0
  32. data/lib/archive_storage/stored_file.rb +94 -0
  33. data/lib/archive_storage/tasks.rake +82 -0
  34. data/lib/archive_storage/verification_result.rb +11 -0
  35. data/lib/archive_storage/verifier.rb +144 -0
  36. data/lib/archive_storage/version.rb +5 -0
  37. data/lib/archive_storage.rb +148 -0
  38. data/lib/generators/archive_storage/install_generator.rb +28 -0
  39. data/lib/generators/archive_storage/templates/create_archive_storage_files.rb +53 -0
  40. metadata +227 -0
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "models/file_record"
5
+
6
+ module ArchiveStorage
7
+ class Registry
8
+ def available?
9
+ defined?(::ActiveRecord::Base) &&
10
+ ::ActiveRecord::Base.connected? &&
11
+ record_class.table_exists?
12
+ rescue StandardError
13
+ false
14
+ end
15
+
16
+ def find_for_uploader(uploader, identifier:, storage_key:)
17
+ return nil unless available?
18
+ return nil unless uploader_identity_available?(uploader)
19
+
20
+ record_class.find_by(
21
+ record_type: uploader.model.class.name,
22
+ record_id: uploader.model.id,
23
+ mounted_as: uploader.mounted_as.to_s,
24
+ identifier: identifier.to_s,
25
+ storage_key: storage_key.to_s
26
+ )
27
+ end
28
+
29
+ def current_storage_for(uploader, identifier:, storage_key:, default:)
30
+ find_for_uploader(
31
+ uploader,
32
+ identifier: identifier,
33
+ storage_key: storage_key
34
+ )&.current_storage&.to_sym || default
35
+ end
36
+
37
+ def upsert_for_uploader(uploader, identifier:, storage_key:, current_storage:, metadata: {})
38
+ return nil unless available?
39
+ return nil unless uploader_identity_available?(uploader)
40
+
41
+ record = record_class.find_or_initialize_by(
42
+ record_type: uploader.model.class.name,
43
+ record_id: uploader.model.id,
44
+ mounted_as: uploader.mounted_as.to_s,
45
+ identifier: identifier.to_s,
46
+ storage_key: storage_key.to_s
47
+ )
48
+
49
+ record.uploader = uploader.class.name
50
+ record.current_storage = current_storage.to_s
51
+ record.byte_size ||= metadata[:byte_size]
52
+ record.content_type ||= metadata[:content_type]
53
+ record.checksum ||= metadata[:checksum]
54
+ record.save!
55
+ record
56
+ end
57
+
58
+ def claim_candidate(candidate)
59
+ raise RegistryUnavailableError, "archive_storage_files table is not available" unless available?
60
+
61
+ record = record_class.find_or_initialize_by(
62
+ record_type: candidate.record.class.name,
63
+ record_id: candidate.record.id,
64
+ mounted_as: candidate.mounted_as.to_s,
65
+ identifier: candidate.identifier.to_s,
66
+ storage_key: candidate.storage_key.to_s
67
+ )
68
+ return nil unless claimable?(record)
69
+
70
+ record.uploader = candidate.uploader.class.name
71
+ record.current_storage = candidate.current_storage.to_s
72
+ record.source_storage = candidate.current_storage.to_s
73
+ record.target_storage = candidate.target_storage.to_s
74
+ record.source_storage_key = candidate.source_storage_key.to_s if record.respond_to?(:source_storage_key=)
75
+ record.target_storage_key = candidate.target_storage_key.to_s if record.respond_to?(:target_storage_key=)
76
+ record.enqueued_at = Time.now if record.respond_to?(:enqueued_at=)
77
+ record.source_delete_pending = false if record.respond_to?(:source_delete_pending=) && record.new_record?
78
+ record.byte_size ||= candidate.byte_size
79
+ record.content_type ||= candidate.content_type
80
+ record.save!
81
+ record
82
+ end
83
+
84
+ alias ensure_for_candidate claim_candidate
85
+
86
+ private
87
+
88
+ def record_class
89
+ ArchiveStorage.configuration.registry_class
90
+ end
91
+
92
+ def claimable?(record)
93
+ return false if record.respond_to?(:migrated_at) && record.migrated_at
94
+ return true unless record.respond_to?(:enqueued_at)
95
+ return true unless record.enqueued_at
96
+
97
+ record.enqueued_at <= Time.now - ArchiveStorage.configuration.enqueue_claim_ttl
98
+ end
99
+
100
+ def uploader_identity_available?(uploader)
101
+ uploader.respond_to?(:model) &&
102
+ uploader.model &&
103
+ uploader.model.respond_to?(:id) &&
104
+ uploader.model.id &&
105
+ uploader.respond_to?(:mounted_as) &&
106
+ uploader.mounted_as
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "migration_rate"
5
+
6
+ module ArchiveStorage
7
+ class ScheduleConfig
8
+ attr_reader :name, :cron, :model, :mounted_as, :uploaders, :migration_rate
9
+
10
+ def initialize(name, cron:, model: nil, mounted_as: nil, uploaders: [], migration_rate: nil)
11
+ @name = name.to_sym
12
+ @cron = cron
13
+ @model = model
14
+ @mounted_as = mounted_as&.to_sym
15
+ @uploaders = uploaders.flatten.compact.map(&:to_s)
16
+ @migration_rate = MigrationRate.new(migration_rate) if migration_rate
17
+ end
18
+
19
+ def entry_name
20
+ name
21
+ end
22
+
23
+ def job_arguments
24
+ {}.tap do |args|
25
+ if mount_schedule?
26
+ args[:model] = model_name
27
+ args[:mounted_as] = mounted_as.to_s
28
+ else
29
+ args[:uploaders] = uploaders
30
+ end
31
+
32
+ args[:migration_rate] = migration_rate.max_files_per_run if migration_rate
33
+ end
34
+ end
35
+
36
+ def validate!
37
+ raise ConfigurationError, "archive_storage schedule #{name.inspect} requires cron" if cron.nil? || cron == ""
38
+ return if mount_schedule? || uploaders.any?
39
+
40
+ raise ConfigurationError, "archive_storage schedule #{name.inspect} requires model/mounted_as or uploader"
41
+ end
42
+
43
+ def good_job_entry
44
+ validate!
45
+
46
+ {
47
+ cron: cron,
48
+ class: "ArchiveStorage::Jobs::QueueJob",
49
+ set: { queue: ArchiveStorage.configuration.schedule_queue },
50
+ args: [job_arguments]
51
+ }
52
+ end
53
+
54
+ def sidekiq_cron_entry
55
+ validate!
56
+
57
+ {
58
+ "cron" => cron,
59
+ "class" => "ArchiveStorage::Jobs::SidekiqQueueWorker",
60
+ "queue" => ArchiveStorage.configuration.schedule_queue.to_s,
61
+ "args" => [stringify_keys(job_arguments)]
62
+ }
63
+ end
64
+
65
+ private
66
+
67
+ def mount_schedule?
68
+ model && mounted_as
69
+ end
70
+
71
+ def model_name
72
+ model.respond_to?(:name) ? model.name : model.to_s
73
+ end
74
+
75
+ def stringify_keys(hash)
76
+ hash.transform_keys(&:to_s)
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchiveStorage
4
+ class << self
5
+ def install_scheduled_jobs!(rails_config: nil)
6
+ if configuration.job_backend.to_sym == :sidekiq
7
+ install_sidekiq_schedules!
8
+ else
9
+ return false unless good_job_available?
10
+
11
+ install_good_job_cron!(rails_config: rails_config)
12
+ end
13
+ end
14
+
15
+ def install_good_job_cron!(rails_config: nil)
16
+ entries = good_job_cron
17
+ return false if entries.empty?
18
+
19
+ config = rails_config || rails_application_config
20
+ return false unless config&.respond_to?(:good_job)
21
+
22
+ good_job_config = config.good_job
23
+ good_job_config.cron = cron_hash(good_job_config.cron).merge(entries)
24
+ true
25
+ end
26
+
27
+ def install_sidekiq_schedules!
28
+ install_sidekiq_cron! || install_sidekiq_scheduler!
29
+ end
30
+
31
+ def install_sidekiq_cron!
32
+ require "sidekiq"
33
+ require "sidekiq-cron"
34
+ require_relative "jobs/sidekiq_queue_worker"
35
+
36
+ ::Sidekiq.configure_server do |config|
37
+ config.on(:startup) do
38
+ entries = ArchiveStorage.sidekiq_cron
39
+ ::Sidekiq::Cron::Job.load_from_hash(entries) if entries.any?
40
+ end
41
+ end
42
+
43
+ true
44
+ rescue LoadError
45
+ false
46
+ end
47
+
48
+ def install_sidekiq_scheduler!
49
+ require "sidekiq"
50
+ require "sidekiq-scheduler"
51
+ require_relative "jobs/sidekiq_queue_worker"
52
+
53
+ ::Sidekiq.configure_server do |config|
54
+ config.on(:startup) do
55
+ entries = ArchiveStorage.sidekiq_cron
56
+ entries.each { |name, entry| ::Sidekiq.set_schedule(name, entry) }
57
+ reload_sidekiq_scheduler if entries.any?
58
+ end
59
+ end
60
+
61
+ true
62
+ rescue LoadError
63
+ false
64
+ end
65
+
66
+ private
67
+
68
+ def cron_hash(value)
69
+ return {} if value.nil?
70
+ return value.to_h if value.respond_to?(:to_h)
71
+
72
+ {}
73
+ end
74
+
75
+ def good_job_available?
76
+ defined?(::GoodJob) || Gem.loaded_specs.key?("good_job")
77
+ end
78
+
79
+ def reload_sidekiq_scheduler
80
+ if defined?(::SidekiqScheduler::Scheduler)
81
+ ::SidekiqScheduler::Scheduler.instance.reload_schedule!
82
+ elsif defined?(::Sidekiq::Scheduler) && ::Sidekiq::Scheduler.respond_to?(:reload_schedule!)
83
+ ::Sidekiq::Scheduler.reload_schedule!
84
+ end
85
+ end
86
+
87
+ def rails_application_config
88
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:application)
89
+
90
+ ::Rails.application&.config
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "stored_file"
4
+
5
+ module ArchiveStorage
6
+ class Storage
7
+ attr_reader :uploader, :identifier
8
+
9
+ def initialize(uploader)
10
+ @uploader = uploader
11
+ end
12
+
13
+ def store!(file)
14
+ identifier = identifier_for(file)
15
+ @identifier = identifier
16
+ storage_key = uploader.store_path(identifier)
17
+ primary_storage = policy.primary_storage_key
18
+ adapter = ArchiveStorage.adapter(primary_storage)
19
+
20
+ adapter.upload(storage_key, file)
21
+ metadata = safe_head(adapter, storage_key)
22
+
23
+ ArchiveStorage.registry.upsert_for_uploader(
24
+ uploader,
25
+ identifier: identifier,
26
+ storage_key: storage_key,
27
+ current_storage: primary_storage,
28
+ metadata: {
29
+ byte_size: metadata&.byte_size,
30
+ content_type: metadata&.content_type,
31
+ checksum: metadata&.etag
32
+ }
33
+ )
34
+
35
+ StoredFile.new(uploader, identifier, storage_key: storage_key)
36
+ end
37
+
38
+ def retrieve!(identifier)
39
+ @identifier = identifier
40
+ StoredFile.new(
41
+ uploader,
42
+ identifier,
43
+ storage_key: uploader.store_path(identifier)
44
+ )
45
+ end
46
+
47
+ def cache!(new_file)
48
+ file_cache_storage.cache!(new_file)
49
+ end
50
+
51
+ def retrieve_from_cache!(identifier)
52
+ file_cache_storage.retrieve_from_cache!(identifier)
53
+ end
54
+
55
+ def delete_dir!(path)
56
+ file_cache_storage.delete_dir!(path)
57
+ end
58
+
59
+ def clean_cache!(seconds)
60
+ file_cache_storage.clean_cache!(seconds)
61
+ end
62
+
63
+ private
64
+
65
+ def identifier_for(file)
66
+ return file.filename if file.respond_to?(:filename) && file.filename
67
+ return uploader.filename if uploader.respond_to?(:filename) && uploader.filename
68
+
69
+ raise ArgumentError, "cannot infer upload identifier for archive storage upload"
70
+ end
71
+
72
+ def policy
73
+ ArchiveStorage.policy_for_uploader(uploader) ||
74
+ raise(ConfigurationError, "archive_storage policy is not configured for #{uploader.class.name}")
75
+ end
76
+
77
+ def safe_head(adapter, storage_key)
78
+ adapter.head(storage_key)
79
+ rescue StandardError
80
+ nil
81
+ end
82
+
83
+ def file_cache_storage
84
+ unless defined?(::CarrierWave::Storage::File)
85
+ raise ConfigurationError, "CarrierWave file storage is required for archive_storage cache storage"
86
+ end
87
+
88
+ @file_cache_storage ||= ::CarrierWave::Storage::File.new(uploader)
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchiveStorage
4
+ class StorageConfig
5
+ attr_accessor :name,
6
+ :provider,
7
+ :endpoint,
8
+ :bucket,
9
+ :access_key_id,
10
+ :secret_access_key,
11
+ :region,
12
+ :path_style,
13
+ :public,
14
+ :public_host,
15
+ :root_path,
16
+ :base_url,
17
+ :adapter,
18
+ :options
19
+
20
+ def initialize(name)
21
+ @name = name.to_sym
22
+ @provider = :s3
23
+ @region = "us-east-1"
24
+ @path_style = false
25
+ @public = false
26
+ @options = {}
27
+ end
28
+
29
+ def path_style?
30
+ !!path_style
31
+ end
32
+
33
+ def public?
34
+ !!public
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchiveStorage
4
+ class StorageRule
5
+ attr_reader :role, :storage_key, :after, :condition, :scope
6
+
7
+ def initialize(role, storage_key, after: nil, condition: nil, scope: nil)
8
+ @role = role.to_sym
9
+ @storage_key = storage_key.to_sym
10
+ @after = after
11
+ @condition = condition
12
+ @scope = scope
13
+ end
14
+
15
+ def eligible?(record, now:, timestamp_attribute:)
16
+ old_enough?(record, now: now, timestamp_attribute: timestamp_attribute) &&
17
+ condition_matches?(record)
18
+ end
19
+
20
+ def scoped?
21
+ !scope.nil?
22
+ end
23
+
24
+ def apply_scope(relation)
25
+ case scope
26
+ when Symbol, String
27
+ relation.public_send(scope)
28
+ when Proc
29
+ scope.arity.zero? ? relation.instance_exec(&scope) : scope.call(relation)
30
+ else
31
+ relation.respond_to?(:merge) ? relation.merge(scope) : scope
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def old_enough?(record, now:, timestamp_attribute:)
38
+ return true unless after
39
+ return false unless record
40
+
41
+ timestamp = record.public_send(timestamp_attribute) if record.respond_to?(timestamp_attribute)
42
+ return false unless timestamp
43
+
44
+ timestamp <= now - seconds_after
45
+ end
46
+
47
+ def condition_matches?(record)
48
+ return true unless condition
49
+
50
+ condition.arity.zero? ? condition.call : condition.call(record)
51
+ end
52
+
53
+ def seconds_after
54
+ after.respond_to?(:to_i) ? after.to_i : after
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module ArchiveStorage
6
+ class StoredFile
7
+ attr_reader :uploader, :identifier, :storage_key
8
+
9
+ def initialize(uploader, identifier, storage_key: nil)
10
+ @uploader = uploader
11
+ @identifier = identifier.to_s
12
+ @storage_key = storage_key || uploader.store_path(identifier)
13
+ end
14
+
15
+ def path
16
+ storage_key
17
+ end
18
+
19
+ def filename
20
+ ::File.basename(identifier)
21
+ end
22
+
23
+ def url(*args, **options)
24
+ positional_options = args.last.is_a?(Hash) ? args.pop : {}
25
+ url_options = positional_options.merge(options)
26
+
27
+ with_read_adapter { |adapter| adapter.url(storage_key, **url_options) }
28
+ end
29
+
30
+ def read
31
+ with_read_adapter { |adapter| adapter.read(storage_key) }
32
+ end
33
+
34
+ def size
35
+ with_read_adapter { |adapter| adapter.head(storage_key).byte_size }
36
+ end
37
+
38
+ def content_type
39
+ with_read_adapter { |adapter| adapter.head(storage_key).content_type }
40
+ end
41
+
42
+ def exists?
43
+ candidate_storages.any? do |storage|
44
+ ArchiveStorage.adapter(storage).exists?(storage_key)
45
+ rescue *fallback_errors
46
+ false
47
+ end
48
+ end
49
+
50
+ def delete
51
+ adapter_for(current_storage).delete(storage_key)
52
+ end
53
+
54
+ def current_storage
55
+ ArchiveStorage.registry.current_storage_for(
56
+ uploader,
57
+ identifier: identifier,
58
+ storage_key: storage_key,
59
+ default: policy.primary_storage_key
60
+ )
61
+ end
62
+
63
+ def candidate_storages
64
+ ([current_storage] + policy.read_fallbacks + [policy.primary_storage_key]).compact.uniq
65
+ end
66
+
67
+ private
68
+
69
+ def with_read_adapter
70
+ last_error = nil
71
+
72
+ candidate_storages.each do |storage|
73
+ return yield adapter_for(storage)
74
+ rescue *fallback_errors => error
75
+ last_error = error
76
+ end
77
+
78
+ raise(last_error || NotFoundError, "object #{storage_key.inspect} not found")
79
+ end
80
+
81
+ def adapter_for(storage)
82
+ ArchiveStorage.adapter(storage)
83
+ end
84
+
85
+ def policy
86
+ ArchiveStorage.policy_for_uploader(uploader) ||
87
+ raise(ConfigurationError, "archive_storage policy is not configured for #{uploader.class.name}")
88
+ end
89
+
90
+ def fallback_errors
91
+ ArchiveStorage.configuration.fallback_on_read_errors
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "archive_storage/planner"
4
+ require "archive_storage/migrator"
5
+ require "archive_storage/jobs/migration_job"
6
+
7
+ namespace :archive_storage do
8
+ desc "Dry-run ArchiveStorage migration"
9
+ task plan: :environment do
10
+ planner = ArchiveStorage::Planner.new(
11
+ uploader: ENV["UPLOADER"],
12
+ model: ENV["MODEL"],
13
+ mounted_as: ENV["MOUNT"],
14
+ older_than: ENV["OLDER_THAN"],
15
+ limit: ENV["LIMIT"],
16
+ estimate_sizes: ENV.fetch("ESTIMATE_SIZES", "true") != "false"
17
+ )
18
+
19
+ puts planner.call.to_text
20
+ end
21
+
22
+ desc "Enqueue or run ArchiveStorage migration"
23
+ task migrate: :environment do
24
+ planner = ArchiveStorage::Planner.new(
25
+ uploader: ENV["UPLOADER"],
26
+ model: ENV["MODEL"],
27
+ mounted_as: ENV["MOUNT"],
28
+ older_than: ENV["OLDER_THAN"],
29
+ limit: ENV["LIMIT"],
30
+ estimate_sizes: true
31
+ )
32
+
33
+ inline = ENV.fetch("INLINE", "false") == "true"
34
+ count = ArchiveStorage::Migrator
35
+ .new(planner: planner)
36
+ .enqueue_or_migrate!(inline: inline)
37
+
38
+ puts "#{inline ? "Migrated" : "Enqueued"} #{count} files"
39
+ end
40
+
41
+ desc "Enqueue ArchiveStorage migration jobs"
42
+ task enqueue: :migrate
43
+
44
+ desc "Verify migrated ArchiveStorage files"
45
+ task verify: :environment do
46
+ scope = ArchiveStorage.configuration.registry_class.where.not(migrated_at: nil)
47
+ count = 0
48
+
49
+ scope.find_each do |file_record|
50
+ ArchiveStorage::Migrator.new.verify_record!(file_record)
51
+ count += 1
52
+ end
53
+
54
+ puts "Verified #{count} files"
55
+ end
56
+
57
+ desc "Delete verified source copies after cleanup delay"
58
+ task cleanup_source: :environment do
59
+ scope = ArchiveStorage.configuration.registry_class.pending_cleanup
60
+ count = 0
61
+
62
+ scope.find_each do |file_record|
63
+ count += 1 if ArchiveStorage::Migrator.new.cleanup_source!(file_record)
64
+ end
65
+
66
+ puts "Deleted #{count} source copies"
67
+ end
68
+
69
+ desc "Show ArchiveStorage migration status"
70
+ task status: :environment do
71
+ records = ArchiveStorage.configuration.registry_class
72
+
73
+ puts "ArchiveStorage Status"
74
+ puts ""
75
+ puts "Tracked: #{records.count}"
76
+ puts "Migrated: #{records.where.not(migrated_at: nil).count}"
77
+ puts "Verified: #{records.where.not(verified_at: nil).count}"
78
+ puts "Pending cleanup: #{records.pending_cleanup.count}"
79
+ puts "Source deleted: #{records.where.not(source_deleted_at: nil).count}"
80
+ puts "Failed: #{records.where.not(last_error: [nil, ""]).count}"
81
+ end
82
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchiveStorage
4
+ VerificationResult = Struct.new(
5
+ :strategy,
6
+ :matched_by,
7
+ :source_metadata,
8
+ :target_metadata,
9
+ keyword_init: true
10
+ )
11
+ end