s3_migration 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e1e5306758296ed3929c545d4607b6e05e439ffb274df1a4bb6f5af23b9ad803
4
+ data.tar.gz: 9b0c6999c349179acf6e3da35608510442efdf67863950af29387f5e6f217d8c
5
+ SHA512:
6
+ metadata.gz: e74181493a0e761de5be0376a20f4f40dc8916b0bda396e72073b4c3c9463cd04f36443fc7fd635983882d8cff43fdd5b3c61d696fa383ffa3321baf9912b4b3
7
+ data.tar.gz: 49aaf2bf5292519ab484b0b44ea2b679a323ba5d99a7b2b28c609fd3af8a7490bbb22cf4e1e36cef62c20554d9c47bf71b90d425ed1848b918185e03c2635f41
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Max Kadel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # s3_migration
2
+
3
+ Move a Hyrax application's original files from disk storage to S3, with every copy verified and every step reversible.
4
+
5
+ Original files often sit on a shared or network volume that costs far more per GB than object storage. This gem copies them to an S3 bucket through the [valkyrie-shrine](https://github.com/samvera-labs/valkyrie-shrine) adapter, checks each copy before the file's identifier is switched, and then helps you quarantine, roll back or purge the disk copies from a manifest, never by deleting a directory.
6
+
7
+ ## Requirements
8
+
9
+ - Ruby 3.2 or newer, Rails 7.x and Hyrax 5.x, with Valkyrie as the metadata layer.
10
+ - Originals on a plain `Valkyrie::Storage::Disk` adapter (`disk://` ids). The versioned disk adapter that `rails generate hyrax:install` sets up is not supported; the tasks stop with a message explaining why.
11
+ - A `valkyrie-shrine` adapter registered as `:repository_s3`, for the bucket.
12
+
13
+ ## Installation
14
+
15
+ ```ruby
16
+ gem 's3_migration', github: 'samvera-labs/s3_migration'
17
+ ```
18
+
19
+ The rake tasks load with the Rails app; there is nothing to generate.
20
+
21
+ ## Usage
22
+
23
+ Every task that changes anything is a dry run unless you set `APPLY=true`, and the migration itself reports what it would do with `DRY_RUN=true`.
24
+
25
+ | Task | What it does |
26
+ | --- | --- |
27
+ | `s3_migration:report` | Read-only: files and gigabytes by use and storage scheme. |
28
+ | `s3_migration:migrate` | Copy originals to S3, verify them, then switch their identifiers. The disk copy is never touched. |
29
+ | `s3_migration:verify` | Read-only audit of what is already in S3. Get zero problems before deleting anything. |
30
+ | `s3_migration:quarantine` | Re-check each migrated file against the manifest, then move its disk copy into `.quarantine`. |
31
+ | `s3_migration:rollback` | Put a disk copy and its original identifier back, and remove the unused upload. |
32
+ | `s3_migration:purge` | Delete quarantined copies older than `MIN_AGE_DAYS` (default 14). |
33
+
34
+ Options: `DRY_RUN`, `INLINE` (run in the foreground instead of queueing jobs), `LIMIT=<n>` files, `APPLY`, `VERIFY_SHA=false`, `MIN_AGE_DAYS=<positive number>`, `S3_MIGRATION_QUEUE`, and `PROGRESS_EVERY=<n>` (`0` turns progress lines off).
35
+
36
+ The full walkthrough, including creating the bucket, registering the adapter and reclaiming disk space safely, is in [docs/migrating-files-to-s3.md](docs/migrating-files-to-s3.md).
37
+
38
+ ### Multitenant applications
39
+
40
+ The tasks work on whichever repository the app is pointed at, so run them once per tenant. If one bucket serves several tenants, set `S3Migration.scope_provider` to a callable returning the current tenant's label; manifest entries, and therefore quarantine, rollback and purge, are then limited to that tenant.
41
+
42
+ ## Development
43
+
44
+ The specs run in a small Rails app under `spec/internal` that boots Hyrax with the Postgres Valkyrie adapter. They need only Postgres, not Fedora, Solr, Redis or S3.
45
+
46
+ ### Running the specs
47
+
48
+ 1. Start Postgres. [docker-compose.yaml](docker-compose.yaml) runs one on `localhost:5432` with user and password `postgres`, matching the default connection:
49
+
50
+ ```bash
51
+ docker compose up -d --wait postgres
52
+ ```
53
+
54
+ If port 5432 is taken (for example by a local Postgres), pick another and point the specs at it:
55
+
56
+ ```bash
57
+ POSTGRES_PORT=5433 docker compose up -d --wait postgres
58
+ export DATABASE_URL=postgres://postgres:postgres@localhost:5433/s3_migration_test
59
+ ```
60
+
61
+ Any other Postgres works too: set `DATABASE_URL` to it. Without `DATABASE_URL` the specs use `postgres://postgres:postgres@localhost:5432/s3_migration_test`.
62
+
63
+ 2. Install the gems (Ruby 3.2 or newer):
64
+
65
+ ```bash
66
+ bundle install
67
+ ```
68
+
69
+ 3. Run the specs. The first run creates the `s3_migration_test` database and loads `spec/internal/db/schema.rb` into it.
70
+
71
+ ```bash
72
+ bundle exec rspec # whole suite
73
+ bundle exec rspec spec/s3_migration/file_migrator_spec.rb # one file
74
+ bundle exec rspec spec/s3_migration/file_migrator_spec.rb:42 # one example, by line
75
+ bundle exec rspec --only-failures # re-run what failed last time
76
+ ```
77
+
78
+ A full run fails unless line coverage is 100%; the report is written to `coverage/index.html`. A run of a single file always reports low coverage, so ignore that failure when you filter.
79
+
80
+ 4. Lint with `bundle exec rubocop`, then stop Postgres when you are done:
81
+
82
+ ```bash
83
+ docker compose down # add -v to delete the database volume too
84
+ ```
85
+
86
+ ### Troubleshooting
87
+
88
+ - `PG::ConnectionBad` or `connection refused`: Postgres is not up yet, or `DATABASE_URL` points somewhere else. Check `docker compose ps`.
89
+ - Schema changes: after editing `spec/internal/db/schema.rb`, run `docker compose down -v` and start again so the specs reload it into a fresh database.
90
+
91
+ CI (GitHub Actions) runs rubocop and the specs on each push and pull request, and requires both to pass.
92
+
93
+ Every pull request needs one release-note label, which the `Verify` check enforces: `patch-ver` (bug fix), `minor-ver` (new backward-compatible feature), `major-ver` (breaking change), `dependencies` (dependency bump, set by Dependabot) or `ignore-for-release` (CI, docs, tooling).
94
+
95
+ ### Releasing
96
+
97
+ On an up-to-date, clean `main`, bump `S3Migration::VERSION` in `lib/s3_migration/version.rb` and merge that change. Then, signed in to rubygems.org (`gem signin`; the gem requires MFA):
98
+
99
+ ```bash
100
+ bundle exec rake release
101
+ ```
102
+
103
+ This tags `vX.Y.Z`, pushes the tag and publishes the gem. `bundle exec rake build` builds it into `pkg/` without publishing.
104
+
105
+ ## License
106
+
107
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,74 @@
1
+ # Migrating original files from disk to S3
2
+
3
+ Without an S3 storage adapter, Valkyrie writes original files to the disk adapter (`storage/files` by default) as `disk://` identifiers. That directory often ends up on a shared or network volume (NFS, EFS, a SAN, a mounted block device), which can cost far more per GB than object storage, and which has to be sized, backed up and shared between every app and worker process. This guide moves new and existing originals to S3 (or an S3-compatible service such as MinIO) so they stop living on that volume.
4
+
5
+ Only originals move. Derivatives, uploads and similar stay on the shared storage directories.
6
+
7
+ ## Requirements
8
+
9
+ - Originals are on a plain `Valkyrie::Storage::Disk` adapter (`disk://` ids), registered as `:disk`. If it is registered under another name, set `S3Migration::SourceStorage.adapter_name = :that_name`. Apps on a versioned disk adapter (`versiondisk://` ids, which `rails generate hyrax:install` sets up) or on Fedora are not supported: they keep file versions and deletion markers that a copy to S3 would lose. The tasks stop with a clear message in that case.
10
+ - A Hyrax 5 application on Rails 7. The tasks read file sets and their original files through `Hyrax.query_service` and `Hyrax.custom_queries`, so Valkyrie must be the metadata layer (Wings-only apps are not covered).
11
+ - The destination is a [valkyrie-shrine](https://github.com/samvera-labs/valkyrie-shrine) S3 adapter registered as `:repository_s3` (step 2).
12
+
13
+ ## 1. Create the bucket
14
+
15
+ Use a private bucket with versioning on and public access blocked. Putting each client or deployment in its own bucket keeps access, lifecycle and cost accounting separate. Place it close to the app servers rather than to end users: the app reads files from the bucket and streams them to users, so distance between the app and the bucket adds latency and, on some providers, transfer cost.
16
+
17
+ ## 2. Register the S3 adapter and make it the default
18
+
19
+ The app needs a Valkyrie storage adapter registered as `:repository_s3` (the generated `config/initializers/1_valkyrie.rb` has a commented example using the `REPOSITORY_S3_*` variables) and set as `Valkyrie.config.storage_adapter` so new files go there. Give the app read/write/delete and list access to the bucket, either through the credentials your platform provides (instance role, workload identity, environment credentials) or through the adapter's key options. Restart every process that writes files (web and background workers), and clear caches (including any IIIF/derivative caches).
20
+
21
+ New files now get `shrine://` identifiers. Existing files keep working from disk until migrated.
22
+
23
+ ## 3. Migrate existing files
24
+
25
+ `s3_migration:migrate` enqueues one background job per file set (`S3Migration::MigrateFilesJob` lists the disk files, `S3Migration::MigrateFileSetJob` moves each one), so speed scales with the number of job workers. Each job uploads the file, reads it back from S3 and compares its SHA-256 with the disk file, and only then switches the identifier; on a mismatch the upload is removed and the file stays on disk. The S3 adapter also verifies each upload on its own, comparing an MD5 of the source file with the object's ETag, which reads the source file one more time. Before uploading, the disk file is also checked against the checksum Hyrax recorded when the file was characterized (an MD5, optionally written as `urn:md5:`, `urn:sha1:` or `urn:sha256:`); a mismatch is reported as `suspect` and the file is not migrated. Transient errors retry. The disk copy is never touched, and the task can be re-run safely (migrated files are skipped).
26
+
27
+ Start with a dry run, which runs inline and prints what it would do. It reads every file to check checksums, so it takes a while on large repositories:
28
+
29
+ ```bash
30
+ DRY_RUN=true bundle exec rake s3_migration:migrate
31
+ ```
32
+
33
+ Then a small real batch, run inline so you can read the output, followed by the full run, which enqueues jobs:
34
+
35
+ ```bash
36
+ INLINE=true LIMIT=5 bundle exec rake s3_migration:migrate
37
+ bundle exec rake s3_migration:migrate
38
+ ```
39
+
40
+ Run these wherever you normally run rake tasks against the app (a console on an app host, a container, an orchestrator job).
41
+
42
+ Things to know when running at scale:
43
+
44
+ - Set `S3_MIGRATION_QUEUE` to run the jobs on a dedicated queue (your workers must be listening to it) so ingest and derivative jobs are not starved.
45
+ - Per-file results are logged as `[S3Migration] <file set id> <status>`.
46
+ - File sets are not re-indexed: the migration changes only where the bytes are stored, so the search index (including extracted full text) is left exactly as it was. Re-indexing would also re-read every file from S3 and re-run text extraction.
47
+ - File sets with no original file (for example empty file sets left by an import) are reported as `NO_ORIGINAL` and skipped; nothing is moved or deleted for them.
48
+ - Long inline runs, reports and audits print a progress line every 500 items with the elapsed time and rate. Change the interval with `PROGRESS_EVERY=<n>`, or set it to `0` to turn it off.
49
+ - Every migrated file also gets a manifest object in the bucket (see step 5).
50
+ - The live site reads from the same storage while this runs, so start with few workers and watch the storage's throughput and latency.
51
+
52
+ ### Applications that partition one repository (for example multitenant apps)
53
+
54
+ The tasks work on the repository the app is currently pointed at, so run them once per partition with whatever mechanism your app has for that. If one bucket serves several partitions, set `S3Migration.scope_provider` to a callable returning a label for the current partition (for example in an initializer); manifest entries are then stored per label, so the quarantine and rollback steps only touch the current partition's files.
55
+
56
+ ## 4. Verify
57
+
58
+ - Audit what is in S3 (read-only; checks the object exists, the recorded size and the stored checksum): `bundle exec rake s3_migration:verify`. Expect `Problems: 0`.
59
+ - A newly created work's original has a `shrine://` identifier.
60
+ - Old and new works still display their files (viewer/IIIF, downloads) after clearing caches.
61
+ - Thumbnails are unaffected (they are derivatives).
62
+ - A re-run of the migration reports nothing left to migrate.
63
+
64
+ ## 5. Reclaim the disk space
65
+
66
+ Every migrated file is recorded in a manifest object in the bucket (`_migration-manifests/<file set id>.json`: file set and file IDs, old disk id, new S3 id, size, SHA-256). Deletion is manifest-driven, never `rm -rf storage/files`.
67
+
68
+ 1. Take a snapshot or backup of the storage volume, and keep bucket versioning on.
69
+ 2. `bundle exec rake s3_migration:report` lists what is still on disk by file use and storage scheme. The migration only moves originals; extracted text, other file versions and similar may still be `disk://` and are not covered by the manifest.
70
+ 3. Dry-run, then apply: `bundle exec rake s3_migration:quarantine` (add `APPLY=true`). For each file it re-checks that the database still points at the S3 id and that the S3 object matches the manifest size and SHA-256, then moves the disk copy to `<storage>/.quarantine` (a rename within the same volume).
71
+ 4. Soak (at least a week or two, through a backup cycle), re-checking the site. To undo, `bundle exec rake s3_migration:rollback` (dry-run by default, `APPLY=true`) checks the disk copy against the manifest, restores it and the original identifier, then removes the S3 upload and manifest entry that are no longer used (bucket versioning keeps the removed object recoverable).
72
+ 5. Permanently delete: `bundle exec rake s3_migration:purge` (dry-run by default; `APPLY=true`, `MIN_AGE_DAYS=14`, which must be a positive number). Like every step above it works from the manifest: it deletes only the quarantined copies of files in the manifest scope it runs under (see `S3Migration.scope_provider`), and leaves anything else in `.quarantine`.
73
+
74
+ Do a non-production environment first and production after, checking again between each. Storage is usually billed or provisioned by stored bytes, so the saving only appears after step 5 (and, on volumes you size by hand, after you shrink them). Derivatives often remain on the volume; size them separately. If your storage offers cheaper tiers for rarely read files, enabling them is a lower-risk way to cut cost while you work through the steps above.
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Migration
4
+ # Read-only check of one original file already in S3: the object must be readable, match the
5
+ # recorded size, and match the stored checksum when one is present.
6
+ class Auditor
7
+ Result = Struct.new(:status, :problems, keyword_init: true)
8
+
9
+ def initialize(original:, storage: Valkyrie::StorageAdapter)
10
+ @original = original
11
+ @storage = storage
12
+ end
13
+
14
+ # @return [Result] status is :ok, :problem or :not_in_s3
15
+ def call
16
+ return Result.new(status: :not_in_s3, problems: []) unless original.file_identifier.to_s.start_with?('shrine://')
17
+
18
+ s3_file = storage.find_by(id: original.file_identifier)
19
+ problems = size_problems(s3_file) + checksum_problems(s3_file)
20
+ Result.new(status: problems.empty? ? :ok : :problem, problems:)
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :original, :storage
26
+
27
+ def size_problems(s3_file)
28
+ recorded = original.recorded_size.first
29
+ return [] unless recorded && s3_file.size.to_i != recorded.to_i
30
+
31
+ ["size #{s3_file.size} != recorded #{recorded}"]
32
+ end
33
+
34
+ def checksum_problems(s3_file)
35
+ stored = StoredChecksum.for(original)
36
+ return [] unless stored
37
+
38
+ actual = s3_file.checksum(digests: [stored.digest_class.new]).first
39
+ actual == stored.digest ? [] : ["#{stored.algorithm} #{actual} != stored #{stored.digest}"]
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Migration
4
+ # Holds a lock on one file set while a block runs, so two jobs never migrate it at the same time (a double
5
+ # enqueue, or a retry while the first attempt is still running). It is the Redis lock Hyrax takes for works.
6
+ # A job that cannot get the lock in time raises Hyrax::LockManager::UnableToAcquireLockError and is retried.
7
+ module FileLock
8
+ DEFAULT_TTL_SECONDS = 900
9
+
10
+ def self.synchronize(file_set_id, &)
11
+ manager.lock("s3_migration:#{file_set_id}", &)
12
+ end
13
+
14
+ # The lock expires on its own if a worker dies holding it. S3_MIGRATION_LOCK_TTL (seconds) must exceed the
15
+ # time to upload the largest original.
16
+ def self.manager
17
+ ttl = ENV.fetch('S3_MIGRATION_LOCK_TTL', DEFAULT_TTL_SECONDS).to_i * 1000
18
+ Hyrax::LockManager.new(ttl, Hyrax.config.lock_retry_count, Hyrax.config.lock_retry_delay)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Migration
4
+ # Copies one original file from the disk storage adapter to the S3 repository adapter and
5
+ # switches its identifier only after the copy is verified. Never touches the disk copy.
6
+ # Unexpected errors are raised (so a job can retry); an upload the database does not point at is removed.
7
+ class FileMigrator
8
+ Result = Struct.new(:status, :message, :entry, keyword_init: true)
9
+
10
+ CHUNK_SIZE = 1_048_576
11
+
12
+ # @param file_set [Valkyrie::Resource] a file set
13
+ # @param original [Hyrax::FileMetadata] the file set's original file
14
+ def initialize(file_set:, original:, dry_run: false, s3_storage: nil, manifest: nil)
15
+ @file_set = file_set
16
+ @original = original
17
+ @dry_run = dry_run
18
+ @s3_storage = s3_storage || Valkyrie::StorageAdapter.find(:repository_s3)
19
+ @manifest = manifest
20
+ end
21
+
22
+ # @return [Result] status is one of :migrated, :would_migrate, :already_migrated,
23
+ # :unrecognised, :missing, :suspect, :mismatch or :raced
24
+ def call
25
+ skipped = precheck
26
+ return skipped if skipped
27
+ return result(:would_migrate) if dry_run
28
+
29
+ FileLock.synchronize(file_set.id) { migrate }
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :file_set, :original, :dry_run, :s3_storage
35
+
36
+ def manifest
37
+ @manifest ||= Manifest.new
38
+ end
39
+
40
+ def identifier
41
+ original.file_identifier.to_s
42
+ end
43
+
44
+ def disk_path
45
+ SourceStorage.path_for(identifier)
46
+ end
47
+
48
+ def filename
49
+ Array(original.original_filename).first || File.basename(disk_path)
50
+ end
51
+
52
+ def precheck
53
+ return result(:already_migrated) if identifier.start_with?('shrine://')
54
+ return result(:unrecognised, identifier) unless SourceStorage.handles?(identifier)
55
+ return result(:missing, disk_path) unless File.exist?(disk_path)
56
+
57
+ suspect = stored_checksum_mismatch
58
+ result(:suspect, "disk file does not match stored checksum #{suspect}") if suspect
59
+ end
60
+
61
+ def stored_checksum
62
+ return @stored_checksum if defined?(@stored_checksum)
63
+
64
+ @stored_checksum = StoredChecksum.for(original)
65
+ end
66
+
67
+ def stored_checksum_mismatch
68
+ stored = stored_checksum
69
+ stored.to_s if stored && disk_digests[:stored] != stored.digest
70
+ end
71
+
72
+ # One pass feeds both digests. The S3 adapter then reads the file again to upload it, and once more to
73
+ # verify the upload (its ETag check).
74
+ def disk_digests
75
+ @disk_digests ||= begin
76
+ sha256 = Digest::SHA256.new
77
+ stored = stored_checksum&.digest_class&.new
78
+ File.open(disk_path, 'rb') do |io|
79
+ while (chunk = io.read(CHUNK_SIZE))
80
+ sha256 << chunk
81
+ stored << chunk if stored
82
+ end
83
+ end
84
+ { sha256: sha256.hexdigest, stored: stored&.hexdigest }
85
+ end
86
+ end
87
+
88
+ def migrate
89
+ uploaded = nil
90
+ disk_sha256 = disk_digests[:sha256]
91
+ uploaded = upload_original
92
+ outcome = if copy_matches?(uploaded, disk_sha256)
93
+ switch_identifier(uploaded, disk_sha256)
94
+ else
95
+ result(:mismatch, 'S3 copy does not match disk (size/SHA-256)')
96
+ end
97
+ ensure
98
+ discard_upload(uploaded) if uploaded && outcome&.status != :migrated
99
+ end
100
+
101
+ def switch_identifier(uploaded, disk_sha256)
102
+ current = Hyrax.query_service.find_by(id: original.id)
103
+ unless SourceStorage.handles?(current.file_identifier)
104
+ return result(:raced, 'already migrated by another process')
105
+ end
106
+
107
+ entry = build_entry(uploaded, disk_sha256)
108
+ manifest.write(entry)
109
+ persist(current, uploaded)
110
+ result(:migrated, nil, entry)
111
+ end
112
+
113
+ # The adapter checks the copy inside upload, so a failure there leaves an object under a key we never see.
114
+ def upload_original
115
+ watch = UploadWatch.new(s3_storage:, file_set_id: file_set.id)
116
+ File.open(disk_path, 'rb') do |io|
117
+ s3_storage.upload(file: io, original_filename: filename, resource: file_set)
118
+ end
119
+ rescue StandardError
120
+ watch&.discard_new
121
+ raise
122
+ end
123
+
124
+ def discard_upload(uploaded)
125
+ UploadCleanup.new(file_set_id: file_set.id, file_metadata_id: original.id, s3_storage:,
126
+ manifest:).call(uploaded.id)
127
+ end
128
+
129
+ def copy_matches?(uploaded, disk_sha256)
130
+ s3_storage.find_by(id: uploaded.id).valid?(size: File.size(disk_path), digests: { 'sha256' => disk_sha256 })
131
+ end
132
+
133
+ def persist(current, uploaded)
134
+ current.file_identifier = uploaded.id
135
+ Hyrax.persister.save(resource: current)
136
+ end
137
+
138
+ def build_entry(uploaded, disk_sha256)
139
+ { file_set_id: file_set.id.to_s,
140
+ file_metadata_id: original.id.to_s, old_id: identifier, new_id: uploaded.id.to_s,
141
+ size: File.size(disk_path), sha256: disk_sha256, at: Time.current.utc.iso8601 }
142
+ end
143
+
144
+ def result(status, message = nil, entry = nil)
145
+ Result.new(status:, message:, entry:)
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Migration
4
+ # One JSON object per migrated file set under PREFIX in the repository bucket, written through
5
+ # the S3 storage adapter, so parallel jobs never share a buffer and no separate S3 configuration
6
+ # is needed. Entries are kept per S3Migration.scope when one is set.
7
+ class Manifest
8
+ PREFIX = '_migration-manifests'
9
+
10
+ def initialize(storage: nil, scope: S3Migration.scope)
11
+ @shrine = (storage || Valkyrie::StorageAdapter.find(:repository_s3)).shrine
12
+ @scope = scope
13
+ end
14
+
15
+ def write(entry)
16
+ shrine.upload(StringIO.new(entry.to_json), key_for(entry.fetch(:file_set_id)))
17
+ end
18
+
19
+ def delete(file_set_id)
20
+ shrine.delete(key_for(file_set_id))
21
+ end
22
+
23
+ def read(file_set_id)
24
+ parse(shrine.open(key_for(file_set_id)))
25
+ rescue Shrine::FileNotFound
26
+ nil
27
+ end
28
+
29
+ def each_entry
30
+ return enum_for(:each_entry) unless block_given?
31
+
32
+ shrine.bucket.objects(prefix: "#{[shrine.prefix, PREFIX, scope].compact.join('/')}/").each do |object|
33
+ yield parse(object.get.body)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :shrine, :scope
40
+
41
+ def key_for(file_set_id)
42
+ "#{[PREFIX, scope].compact.join('/')}/#{file_set_id}.json"
43
+ end
44
+
45
+ def parse(io)
46
+ JSON.parse(io.read, symbolize_names: true)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Migration
4
+ # Moves one file set's original file from disk to S3. Unexpected errors are raised so the job retries;
5
+ # verification failures (suspect, mismatch, ...) are logged and left on disk.
6
+ class MigrateFileSetJob < ActiveJob::Base
7
+ queue_as { ENV.fetch('S3_MIGRATION_QUEUE', 'default') }
8
+ retry_on StandardError, wait: :polynomially_longer, attempts: 5
9
+
10
+ def perform(file_set_id, dry_run: false)
11
+ file_set = Hyrax.query_service.find_by(id: file_set_id)
12
+ original = Hyrax.custom_queries.find_original_file(file_set:)
13
+ return unless original
14
+
15
+ result = S3Migration::FileMigrator.new(file_set:, original:, dry_run:).call
16
+ Rails.logger.info("[S3Migration] #{file_set_id} #{result.status} #{result.message}".strip)
17
+ result
18
+ rescue Valkyrie::Persistence::ObjectNotFoundError
19
+ Rails.logger.info("[S3Migration] #{file_set_id} not found; skipping")
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Migration
4
+ # Enqueues a MigrateFileSetJob for every file set whose original is still on disk.
5
+ class MigrateFilesJob < ActiveJob::Base
6
+ queue_as { ENV.fetch('S3_MIGRATION_QUEUE', 'default') }
7
+
8
+ def perform(limit: nil)
9
+ enqueued = 0
10
+ S3Migration.each_file_set do |file_set|
11
+ break if limit && enqueued >= limit
12
+ next unless on_disk?(file_set)
13
+
14
+ MigrateFileSetJob.perform_later(file_set.id.to_s)
15
+ enqueued += 1
16
+ end
17
+ Rails.logger.info("[S3Migration] enqueued #{enqueued} file sets")
18
+ enqueued
19
+ end
20
+
21
+ private
22
+
23
+ def on_disk?(file_set)
24
+ on_error = ->(fs, error) { Rails.logger.warn("[S3Migration] #{fs.id} has no readable original: #{error.class}") }
25
+ S3Migration.original_for(file_set, on_error:)&.file_identifier.then { |identifier| identifier && S3Migration::SourceStorage.handles?(identifier) }
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Migration
4
+ # Prints a progress line every PROGRESS_EVERY items (default 500; 0 turns it off), so long runs show
5
+ # signs of life, how fast they are going, and what they have found so far.
6
+ class Progress
7
+ # @param detail [#call, nil] returns extra text for each line, e.g. running counts
8
+ def initialize(every: ENV.fetch('PROGRESS_EVERY', 500).to_i, out: $stdout, detail: nil, clock: lambda {
9
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
10
+ })
11
+ @every = every
12
+ @out = out
13
+ @detail = detail
14
+ @clock = clock
15
+ @started = clock.call
16
+ @count = 0
17
+ end
18
+
19
+ def tick
20
+ @count += 1
21
+ report if @every.positive? && (@count % @every).zero?
22
+ end
23
+
24
+ private
25
+
26
+ def report
27
+ elapsed = @clock.call - @started
28
+ rate = @count / [elapsed, 0.001].max
29
+ line = format(' ... %<count>d examined (%<rate>.1f/s, %<elapsed>s elapsed)',
30
+ count: @count, rate:, elapsed: duration(elapsed))
31
+ line += " #{@detail.call}" if @detail
32
+ @out.puts(line)
33
+ @out.flush
34
+ end
35
+
36
+ def duration(seconds)
37
+ minutes, secs = seconds.to_i.divmod(60)
38
+ hours, minutes = minutes.divmod(60)
39
+ return format('%<hours>d:%<minutes>02d:%<secs>02d', hours:, minutes:, secs:) if hours.positive?
40
+
41
+ format('%<minutes>d:%<secs>02d', minutes:, secs:)
42
+ end
43
+ end
44
+ end