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 +7 -0
- data/LICENSE +21 -0
- data/README.md +107 -0
- data/docs/migrating-files-to-s3.md +74 -0
- data/lib/s3_migration/auditor.rb +42 -0
- data/lib/s3_migration/file_lock.rb +21 -0
- data/lib/s3_migration/file_migrator.rb +148 -0
- data/lib/s3_migration/manifest.rb +49 -0
- data/lib/s3_migration/migrate_file_set_job.rb +22 -0
- data/lib/s3_migration/migrate_files_job.rb +28 -0
- data/lib/s3_migration/progress.rb +44 -0
- data/lib/s3_migration/quarantine.rb +154 -0
- data/lib/s3_migration/railtie.rb +8 -0
- data/lib/s3_migration/source_storage.rb +45 -0
- data/lib/s3_migration/storage_report.rb +38 -0
- data/lib/s3_migration/stored_checksum.rb +35 -0
- data/lib/s3_migration/tasks.rb +186 -0
- data/lib/s3_migration/upload_cleanup.rb +42 -0
- data/lib/s3_migration/upload_watch.rb +29 -0
- data/lib/s3_migration/version.rb +5 -0
- data/lib/s3_migration.rb +75 -0
- data/lib/tasks/s3_migration.rake +48 -0
- metadata +114 -0
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
|