active_storage-async_variants 0.5.0 → 0.7.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 +4 -4
- data/CHANGELOG.md +15 -0
- data/CLAUDE.md +4 -4
- data/README.md +37 -36
- data/Rakefile +11 -0
- data/app/assets/javascripts/progress-bar.js +281 -0
- data/app/assets/stylesheets/progress.css +35 -0
- data/app/controllers/active_storage/async_variants/callbacks_controller.rb +4 -0
- data/app/views/active_storage/async_variants/states/_failed.html.erb +7 -6
- data/app/views/active_storage/async_variants/states/_processing.html.erb +11 -5
- data/db/migrate/20260607000001_add_heartbeat_columns_to_variant_records.rb +8 -0
- data/features/state_rendering.feature +18 -0
- data/features/step_definitions/dummy_steps.rb +28 -0
- data/lib/active_storage/async_variants/attachment_extension.rb +1 -1
- data/lib/active_storage/async_variants/heartbeat_watchdog_job.rb +40 -0
- data/lib/active_storage/async_variants/helper.rb +19 -0
- data/lib/active_storage/async_variants/preview_extension.rb +21 -25
- data/lib/active_storage/async_variants/process_job.rb +7 -0
- data/lib/active_storage/async_variants/reflection_extension.rb +1 -1
- data/lib/active_storage/async_variants/registry.rb +1 -1
- data/lib/active_storage/async_variants/variant_record_extension.rb +2 -2
- data/lib/active_storage/async_variants/variant_with_record_extension.rb +19 -18
- data/lib/active_storage/async_variants/variation_extension.rb +2 -2
- data/lib/active_storage/async_variants/version.rb +1 -1
- data/lib/active_storage/async_variants.rb +13 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 259c11526314988d60e78e9e176dc16105c6aeb578b1774693a666fc1e277a21
|
|
4
|
+
data.tar.gz: 92fc3546ed708ed709ad4fedc2d7e6258d765719fad75571844671a902f1b485
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ebfe0839de2603c91a5705f3942b30b3db7685d20ce5d3d2425f98472a191d559ac9103bab5a59972e09f18385b8829283fe0323f2de4810e8619cb57e028ab2
|
|
7
|
+
data.tar.gz: 165f5796f098a3f40194a9d893ba07b98bd7b3a7ac8601fda744f5dcb1e4a98c0d69e1514e788f612ea8fa1dbf4bca6c44cbb7ee5ef072fcf5fa13fe03ce5b56
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
## [0.7.0]
|
|
2
|
+
|
|
3
|
+
- **Breaking:** A variant now opts into async processing with `async: true` instead of `processing:`. The `processing:` and `failed:` placeholder options are removed entirely — there is nothing to configure. A variant's `.url` serves the original while pending/processing/failed, and the processed variant once ready.
|
|
4
|
+
- The `async:`-helper UI no longer fetches a placeholder through the representations path on every poll. The processing/failed states render a zero-network sized box with the progress bar floating over it.
|
|
5
|
+
- The failed state now renders the progress bar in its error state (a static red ring with an X, via `@botandrose/progress-bar`'s `error` attribute) rather than a configurable failed SVG. The retry affordance is unchanged.
|
|
6
|
+
- Migration note: replace `processing: <anything>` with `async: true` on each async variant and drop any `failed:` option. Any `:original`/`:blank`/String/Proc placeholder values no longer have an effect.
|
|
7
|
+
|
|
8
|
+
## [0.6.0]
|
|
9
|
+
|
|
10
|
+
- External transforms can now report progress: a `progress` callback records the percent complete and a heartbeat, readable via `#progress` / `#progress_known?` on variants and previews.
|
|
11
|
+
- Processing variants render a progress bar — indeterminate until progress is reported, then a determinate percentage — served as a cached asset. The turbo-frame poll interval now follows `heartbeat_interval` (default 5s) so each refresh lands on fresh progress.
|
|
12
|
+
- A stalled external transform (no heartbeat within `heartbeat_stale_after`, default 60s) is now marked failed automatically — and therefore retryable — instead of hanging in "processing" forever.
|
|
13
|
+
- Added an `ActiveStorage::AsyncVariants.configure { |config| … }` block for setting all options in one place (see the README's Configuration section).
|
|
14
|
+
- Includes a migration adding the `progress` and `last_heartbeat_at` columns; run it before deploying.
|
|
15
|
+
|
|
1
16
|
## [0.5.0]
|
|
2
17
|
|
|
3
18
|
- Added an opt-in retry affordance to the failed state: hovering a failed variant reveals a control that opens a dialog with the error and a "Retry processing" button that re-runs the transform. Disabled by default; enable it with `ActiveStorage::AsyncVariants.retry_visible_if { … }` (the block runs in the view context, so it can check `current_user`).
|
data/CLAUDE.md
CHANGED
|
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|
|
4
4
|
|
|
5
5
|
## What This Is
|
|
6
6
|
|
|
7
|
-
A Rails engine gem that extends Active Storage with async-safe variant processing. Solves the problem where slow transformations (e.g., video transcoding) block requests or fail silently. The `
|
|
7
|
+
A Rails engine gem that extends Active Storage with async-safe variant processing. Solves the problem where slow transformations (e.g., video transcoding) block requests or fail silently. The `async: true` option on a variant definition opts it into async processing.
|
|
8
8
|
|
|
9
9
|
## Commands
|
|
10
10
|
|
|
@@ -19,9 +19,9 @@ bundle exec rspec spec/active_storage/async_variants_spec.rb -e "description" #
|
|
|
19
19
|
|
|
20
20
|
The gem works by prepending extension modules onto Active Storage classes:
|
|
21
21
|
|
|
22
|
-
- **`VariationExtension`** → `ActiveStorage::Variation` — extracts async options (`
|
|
23
|
-
- **`AttachmentExtension`** → `ActiveStorage::Attachment` — hooks into `transform_variants_later` to enqueue `ProcessJob` for variants with `
|
|
24
|
-
- **`VariantWithRecordExtension`** → `ActiveStorage::VariantWithRecord` — overrides URL generation to serve the
|
|
22
|
+
- **`VariationExtension`** → `ActiveStorage::Variation` — extracts async options (`async:`, `transformer:`) from variant config before passing the rest to standard Active Storage
|
|
23
|
+
- **`AttachmentExtension`** → `ActiveStorage::Attachment` — hooks into `transform_variants_later` to enqueue `ProcessJob` for variants with `async: true`
|
|
24
|
+
- **`VariantWithRecordExtension`** → `ActiveStorage::VariantWithRecord` — overrides URL generation to serve the original blob while not ready; adds state query methods (`processed?`, `processing?`, `pending?`, `failed?`)
|
|
25
25
|
- **`ProcessJob`** — background job that determines transformer type (inline vs external) and processes accordingly
|
|
26
26
|
|
|
27
27
|
### Transformer Types
|
data/README.md
CHANGED
|
@@ -19,7 +19,7 @@ bin/rails db:migrate
|
|
|
19
19
|
|
|
20
20
|
## Usage
|
|
21
21
|
|
|
22
|
-
Add `
|
|
22
|
+
Add `async: true` to any named variant to opt into the async pipeline:
|
|
23
23
|
|
|
24
24
|
```ruby
|
|
25
25
|
class User < ApplicationRecord
|
|
@@ -28,12 +28,12 @@ class User < ApplicationRecord
|
|
|
28
28
|
transformer: VideoTranscoder,
|
|
29
29
|
codec: "vp9",
|
|
30
30
|
resolution: "720p",
|
|
31
|
-
|
|
31
|
+
async: true
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
The presence of `
|
|
36
|
+
The presence of `async: true` is what opts a variant into async processing. Without it, variants behave exactly as they do in standard Active Storage. The `transformer:` option is independent -- you can use a custom transformer synchronously, or use the default transformer asynchronously:
|
|
37
37
|
|
|
38
38
|
```ruby
|
|
39
39
|
has_one_attached :video do |attachable|
|
|
@@ -41,12 +41,12 @@ has_one_attached :video do |attachable|
|
|
|
41
41
|
attachable.variant :web,
|
|
42
42
|
transformer: VideoTranscoder,
|
|
43
43
|
codec: "vp9",
|
|
44
|
-
|
|
44
|
+
async: true
|
|
45
45
|
|
|
46
46
|
# Async with default transformer (large image resize that's too slow for inline)
|
|
47
47
|
attachable.variant :thumbnail,
|
|
48
48
|
resize_to_limit: [200, 200],
|
|
49
|
-
|
|
49
|
+
async: true
|
|
50
50
|
|
|
51
51
|
# Sync with custom transformer (fast custom processing, no opt-in needed)
|
|
52
52
|
attachable.variant :watermarked,
|
|
@@ -60,23 +60,23 @@ In views, use the same Active Storage helpers:
|
|
|
60
60
|
<%= video_tag user.video.variant(:web).url %>
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
While the variant is still processing (or has failed), this serves the original video. Once processing completes, it serves the transcoded variant.
|
|
64
64
|
|
|
65
65
|
## `image_tag` / `video_tag` with `async:` and `direct:`
|
|
66
66
|
|
|
67
|
-
For a polish layer that
|
|
67
|
+
For a polish layer that shows a progress bar while a variant is being processed, polls for completion in the background, and optionally serves the finished variant straight from your CDN, the gem adds two options to `image_tag` and `video_tag`:
|
|
68
68
|
|
|
69
69
|
```erb
|
|
70
|
-
<%# Variant URL with the async client wired up:
|
|
70
|
+
<%# Variant URL with the async client wired up: a progress bar while pending,
|
|
71
71
|
polls for completion, swaps to the real image when ready. %>
|
|
72
72
|
<%= image_tag user.avatar.variant(:web), async: true %>
|
|
73
73
|
|
|
74
74
|
<%# When the variant is ready, render its direct CDN/S3 URL instead of
|
|
75
75
|
routing through Rails. While pending/failed, falls back to the
|
|
76
|
-
Rails representation URL (which serves the
|
|
76
|
+
Rails representation URL (which serves the original). %>
|
|
77
77
|
<%= image_tag user.avatar.variant(:web), direct: true %>
|
|
78
78
|
|
|
79
|
-
<%# Both together: direct URL once processed,
|
|
79
|
+
<%# Both together: direct URL once processed, progress bar while pending,
|
|
80
80
|
polling for completion. %>
|
|
81
81
|
<%= image_tag user.avatar.variant(:web), async: true, direct: true %>
|
|
82
82
|
|
|
@@ -101,6 +101,29 @@ The resulting URL is `"#{cdn_host}/#{variant.key}"`.
|
|
|
101
101
|
|
|
102
102
|
No manual wiring is required. The async state partials are self-contained `<turbo-frame>`s. The only requirement is that the host app loads **Turbo** -- the gem depends on `turbo-rails`, which a default Rails app already includes.
|
|
103
103
|
|
|
104
|
+
## Configuration
|
|
105
|
+
|
|
106
|
+
Set options in an initializer. The `configure` block groups them (each is also a plain accessor, e.g. `ActiveStorage::AsyncVariants.cdn_host = …`):
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
# config/initializers/active_storage_async_variants.rb
|
|
110
|
+
ActiveStorage::AsyncVariants.configure do |config|
|
|
111
|
+
config.cdn_host = "https://d1234abcd.cloudfront.net"
|
|
112
|
+
config.heartbeat_interval = 5.seconds
|
|
113
|
+
config.heartbeat_stale_after = 60.seconds
|
|
114
|
+
config.parent_controller = "ApplicationController"
|
|
115
|
+
config.retry_visible_if { current_user&.admin? }
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
| Option | Default | Purpose |
|
|
120
|
+
|--------|---------|---------|
|
|
121
|
+
| `cdn_host` | `nil` | Host for `direct:` URLs (`"#{cdn_host}/#{variant.key}"`); falls back to the storage service URL. |
|
|
122
|
+
| `heartbeat_interval` | `5.seconds` | Expected cadence of progress heartbeats; the processing `<turbo-frame>` re-polls at this rate. |
|
|
123
|
+
| `heartbeat_stale_after` | `60.seconds` | A processing variant with no heartbeat for this long is marked `failed`. Must exceed `heartbeat_interval`. |
|
|
124
|
+
| `parent_controller` | `"ActionController::Base"` | Base class for the gem's controllers, so the retry view can reach your app's `current_user`. Set as a String. |
|
|
125
|
+
| `retry_visible_if` | off | Block (run in the view context) gating the failed-state retry affordance. |
|
|
126
|
+
|
|
104
127
|
## Writing a Transformer
|
|
105
128
|
|
|
106
129
|
Transformers come in two flavors: **inline** (the job blocks until processing completes) and **external** (the job kicks off remote work and a webhook signals completion).
|
|
@@ -204,31 +227,9 @@ variant.failed? # => true if permanently failed
|
|
|
204
227
|
variant.error # => error message string, or nil
|
|
205
228
|
```
|
|
206
229
|
|
|
207
|
-
##
|
|
208
|
-
|
|
209
|
-
The `processing:` option controls what gets served while a variant is being processed. The `failed:` option (optional) controls what gets served once the variant has permanently failed; if omitted, it defaults to the `processing:` value.
|
|
210
|
-
|
|
211
|
-
```ruby
|
|
212
|
-
# Serve the original unprocessed file while processing
|
|
213
|
-
attachable.variant :web, processing: :original
|
|
214
|
-
|
|
215
|
-
# Return nil -- let the view handle it
|
|
216
|
-
attachable.variant :web, processing: :blank
|
|
217
|
-
|
|
218
|
-
# Static URL while processing
|
|
219
|
-
attachable.variant :web, processing: "/placeholders/processing.svg"
|
|
220
|
-
|
|
221
|
-
# Dynamic placeholder via Proc
|
|
222
|
-
attachable.variant :web,
|
|
223
|
-
processing: -> (blob) { "/placeholders/processing.svg" }
|
|
224
|
-
|
|
225
|
-
# Distinct placeholder when permanently failed
|
|
226
|
-
attachable.variant :web,
|
|
227
|
-
processing: :original,
|
|
228
|
-
failed: "/icons/broken.svg"
|
|
229
|
-
```
|
|
230
|
+
## Placeholders
|
|
230
231
|
|
|
231
|
-
|
|
232
|
+
There is nothing to configure. A variant's `.url` serves the original while it is pending, processing, or failed, and the processed variant once ready. With `async: true` on `image_tag`/`video_tag`, the gem instead shows a circular progress bar while processing, and a red error glyph -- the same progress bar in its error state -- once the variant has permanently failed.
|
|
232
233
|
|
|
233
234
|
## Failure Handling
|
|
234
235
|
|
|
@@ -253,7 +254,7 @@ variant.error # => "ffmpeg exited with status 1: ..."
|
|
|
253
254
|
5. The external service processes the file, uploads the result to the destination URL
|
|
254
255
|
6. The external service POSTs to the callback URL with success/failure status
|
|
255
256
|
7. The gem's callback endpoint transitions the `VariantRecord` to `processed` or `failed`
|
|
256
|
-
8. When a view requests the variant URL, the gem checks state and serves the variant or
|
|
257
|
+
8. When a view requests the variant URL, the gem checks state and serves the processed variant or, while pending/processing/failed, the original
|
|
257
258
|
|
|
258
259
|
### Inline transformer flow
|
|
259
260
|
|
|
@@ -261,7 +262,7 @@ variant.error # => "ffmpeg exited with status 1: ..."
|
|
|
261
262
|
4. The job calls the transformer's `process` method, blocking until complete
|
|
262
263
|
5. On success, the output is uploaded, the `VariantRecord` transitions to `processed`
|
|
263
264
|
6. On failure, the error is recorded and the job is re-enqueued (up to 3 attempts)
|
|
264
|
-
7. When a view requests the variant URL, the gem checks state and serves the variant or
|
|
265
|
+
7. When a view requests the variant URL, the gem checks state and serves the processed variant or, while pending/processing/failed, the original
|
|
265
266
|
|
|
266
267
|
## License
|
|
267
268
|
|
data/Rakefile
CHANGED
|
@@ -13,4 +13,15 @@ end
|
|
|
13
13
|
desc "Run rspec and cucumber"
|
|
14
14
|
task all: [:spec, :cucumber]
|
|
15
15
|
|
|
16
|
+
namespace :vendor do
|
|
17
|
+
desc "Pull the latest @botandrose/progress-bar from unpkg.com into app/assets"
|
|
18
|
+
task :progress_bar do
|
|
19
|
+
require "open-uri"
|
|
20
|
+
url = "https://unpkg.com/@botandrose/progress-bar"
|
|
21
|
+
dest = File.expand_path("app/assets/javascripts/progress-bar.js", __dir__)
|
|
22
|
+
File.write(dest, URI.open(url).read)
|
|
23
|
+
puts "Vendored #{url} -> #{dest}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
16
27
|
task default: :all
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
const CIRCLE_RADIUS = 40; // fallback r attribute; CSS shrinks it to fit the stroke
|
|
2
|
+
const PATH_LENGTH = 100; // normalize the arc so dash math is independent of the radius
|
|
3
|
+
|
|
4
|
+
const STYLES = `
|
|
5
|
+
:host {
|
|
6
|
+
--progress-color: #2E7D32;
|
|
7
|
+
--error-color: #7a242f;
|
|
8
|
+
--indeterminate-color: #999;
|
|
9
|
+
--track-color: #333333;
|
|
10
|
+
--progress-duration: 120ms;
|
|
11
|
+
--indeterminate-duration: 1.5s;
|
|
12
|
+
--bar-height: 32px;
|
|
13
|
+
--bar-padding: 8px;
|
|
14
|
+
--circular-size: 64px;
|
|
15
|
+
--circular-thickness: 16;
|
|
16
|
+
display: block;
|
|
17
|
+
overflow: hidden;
|
|
18
|
+
background: var(--track-color);
|
|
19
|
+
border: 1px solid #999;
|
|
20
|
+
border-radius: 4px;
|
|
21
|
+
min-height: var(--bar-height);
|
|
22
|
+
padding: var(--bar-padding);
|
|
23
|
+
font-size: 13px;
|
|
24
|
+
align-content: center;
|
|
25
|
+
box-sizing: border-box;
|
|
26
|
+
position: relative;
|
|
27
|
+
}
|
|
28
|
+
.bar {
|
|
29
|
+
position: absolute;
|
|
30
|
+
top: 0;
|
|
31
|
+
left: 0;
|
|
32
|
+
height: 100%;
|
|
33
|
+
width: 0%;
|
|
34
|
+
background: var(--progress-color);
|
|
35
|
+
transition: width var(--progress-duration) ease;
|
|
36
|
+
z-index: 1;
|
|
37
|
+
}
|
|
38
|
+
.text{position: relative; z-index: 2;}
|
|
39
|
+
:host([error]) .bar {
|
|
40
|
+
background: var(--error-color);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* Indeterminate (linear): no percent set — a fixed-width segment sweeping across the track. */
|
|
44
|
+
:host(:not([percent])) .bar {
|
|
45
|
+
background: var(--indeterminate-color);
|
|
46
|
+
width: 40%;
|
|
47
|
+
animation: indeterminate-linear var(--indeterminate-duration) infinite linear;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@keyframes indeterminate-linear {
|
|
51
|
+
0% { transform: translateX(-100%); }
|
|
52
|
+
100% { transform: translateX(250%); }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* Circular mode: the host box styling is for the linear track, so drop it. */
|
|
56
|
+
:host([mode="circular"]) {
|
|
57
|
+
background: transparent;
|
|
58
|
+
border: none;
|
|
59
|
+
overflow: visible;
|
|
60
|
+
padding: 0;
|
|
61
|
+
min-height: 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.circular {
|
|
65
|
+
position: relative;
|
|
66
|
+
display: inline-flex;
|
|
67
|
+
width: var(--circular-size);
|
|
68
|
+
height: var(--circular-size);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.ring {
|
|
72
|
+
width: 100%;
|
|
73
|
+
height: 100%;
|
|
74
|
+
transform: rotate(-90deg);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.ring circle {
|
|
78
|
+
fill: none;
|
|
79
|
+
stroke-width: var(--circular-thickness);
|
|
80
|
+
/* Shrink the radius so the stroke's outer edge always lands inside the
|
|
81
|
+
100x100 viewBox, no matter how thick --circular-thickness is. */
|
|
82
|
+
r: calc(49px - var(--circular-thickness) * 0.5px);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.track {
|
|
86
|
+
stroke: var(--track-color);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.progress-ring {
|
|
90
|
+
stroke: var(--progress-color);
|
|
91
|
+
stroke-linecap: round;
|
|
92
|
+
stroke-dasharray: ${PATH_LENGTH};
|
|
93
|
+
stroke-dashoffset: ${PATH_LENGTH};
|
|
94
|
+
transition: stroke-dashoffset var(--progress-duration) ease;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
:host(:not([percent])) .progress-ring {
|
|
98
|
+
stroke: var(--indeterminate-color);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* The X of the error glyph; revealed by [error]. */
|
|
102
|
+
.error-mark {
|
|
103
|
+
display: none;
|
|
104
|
+
fill: none;
|
|
105
|
+
stroke: var(--error-color);
|
|
106
|
+
stroke-width: var(--circular-thickness);
|
|
107
|
+
stroke-linecap: round;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.label {
|
|
111
|
+
position: absolute;
|
|
112
|
+
inset: 0;
|
|
113
|
+
display: flex;
|
|
114
|
+
align-items: center;
|
|
115
|
+
justify-content: center;
|
|
116
|
+
color: #fff;
|
|
117
|
+
mix-blend-mode: difference;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* When the host carries its own inline styling (e.g. a color override), opt
|
|
121
|
+
out of the blend trick and just inherit, so the override wins predictably. */
|
|
122
|
+
:host([style]) .label {
|
|
123
|
+
mix-blend-mode: initial;
|
|
124
|
+
color: inherit;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* Indeterminate (circular): no percent set — spin a fixed arc around the ring. */
|
|
128
|
+
:host(:not([percent])) .ring {
|
|
129
|
+
animation: indeterminate-rotate var(--indeterminate-duration) infinite linear;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
:host(:not([percent])) .progress-ring {
|
|
133
|
+
stroke-dashoffset: ${PATH_LENGTH * 0.75};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
@keyframes indeterminate-rotate {
|
|
137
|
+
0% { transform: rotate(-90deg); }
|
|
138
|
+
100% { transform: rotate(270deg); }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* Error (circular): static full ring + X. Last so it beats the indeterminate rules. */
|
|
142
|
+
:host([error]) .track { stroke: var(--error-color); }
|
|
143
|
+
:host([error]) .progress-ring { display: none; }
|
|
144
|
+
:host([error]) .ring { animation: none; }
|
|
145
|
+
:host([error]) .error-mark { display: block; }
|
|
146
|
+
`;
|
|
147
|
+
|
|
148
|
+
const LINEAR_HTML = `
|
|
149
|
+
<div class="bar"></div>
|
|
150
|
+
<div class="text"><slot></slot></div>
|
|
151
|
+
`;
|
|
152
|
+
|
|
153
|
+
const CIRCULAR_HTML = `
|
|
154
|
+
<div class="circular">
|
|
155
|
+
<svg class="ring" viewBox="0 0 100 100">
|
|
156
|
+
<circle class="track" cx="50" cy="50" r="${CIRCLE_RADIUS}"></circle>
|
|
157
|
+
<circle class="progress-ring" cx="50" cy="50" r="${CIRCLE_RADIUS}" pathLength="${PATH_LENGTH}"></circle>
|
|
158
|
+
<path class="error-mark" d="M37 37 L63 63 M63 37 L37 63"></path>
|
|
159
|
+
</svg>
|
|
160
|
+
<span class="label"><slot></slot></span>
|
|
161
|
+
</div>
|
|
162
|
+
`;
|
|
163
|
+
|
|
164
|
+
let styleSheet = null;
|
|
165
|
+
function getStyleSheet() {
|
|
166
|
+
if (!styleSheet) {
|
|
167
|
+
styleSheet = new CSSStyleSheet();
|
|
168
|
+
styleSheet.replaceSync(STYLES);
|
|
169
|
+
}
|
|
170
|
+
return styleSheet;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function clampPercent(value) {
|
|
174
|
+
const number = Number(value);
|
|
175
|
+
if (Number.isNaN(number)) {
|
|
176
|
+
throw new TypeError(`progress-bar: percent must be numeric, got ${JSON.stringify(value)}`);
|
|
177
|
+
}
|
|
178
|
+
return Math.min(100, Math.max(0, number));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
class ProgressBar extends HTMLElement {
|
|
182
|
+
constructor() {
|
|
183
|
+
super();
|
|
184
|
+
this.attachShadow({ mode: 'open' });
|
|
185
|
+
this._percent = null;
|
|
186
|
+
this._renderedMode = null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
connectedCallback() {
|
|
190
|
+
this.render();
|
|
191
|
+
this.setAttribute('role', 'progressbar');
|
|
192
|
+
this.setAttribute('aria-valuemin', '0');
|
|
193
|
+
this.setAttribute('aria-valuemax', '100');
|
|
194
|
+
this.updateBar();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
get percent() {
|
|
198
|
+
return this._percent;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
set percent(value) {
|
|
202
|
+
if (value === null || value === undefined) {
|
|
203
|
+
this.removeAttribute('percent');
|
|
204
|
+
} else {
|
|
205
|
+
this.setAttribute('percent', clampPercent(value));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
get error() {
|
|
210
|
+
return this.hasAttribute('error');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
set error(value) {
|
|
214
|
+
this.toggleAttribute('error', Boolean(value));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
get indeterminate() {
|
|
218
|
+
return !this.hasAttribute('percent');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
get mode() {
|
|
222
|
+
return this.getAttribute('mode') === 'circular' ? 'circular' : 'linear';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
set mode(value) {
|
|
226
|
+
this.setAttribute('mode', value);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
static get observedAttributes() {
|
|
230
|
+
return ['percent', 'mode'];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
234
|
+
if (name === 'percent') {
|
|
235
|
+
this._percent = newValue === null ? null : clampPercent(newValue);
|
|
236
|
+
}
|
|
237
|
+
if (name === 'mode') {
|
|
238
|
+
this.render();
|
|
239
|
+
}
|
|
240
|
+
this.updateBar();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
updateBar() {
|
|
244
|
+
if (this.mode === 'circular') {
|
|
245
|
+
const ring = this.shadowRoot?.querySelector('.progress-ring');
|
|
246
|
+
if (ring) {
|
|
247
|
+
ring.style.strokeDashoffset = this.indeterminate
|
|
248
|
+
? ''
|
|
249
|
+
: String(PATH_LENGTH * (1 - this._percent / 100));
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
const bar = this.shadowRoot?.querySelector('.bar');
|
|
253
|
+
if (bar) {
|
|
254
|
+
// Leave width to the indeterminate CSS animation when unknown.
|
|
255
|
+
bar.style.width = this.indeterminate ? '' : `${this._percent}%`;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (this.indeterminate) {
|
|
260
|
+
this.removeAttribute('aria-valuenow');
|
|
261
|
+
} else {
|
|
262
|
+
this.setAttribute('aria-valuenow', String(this._percent));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
render() {
|
|
267
|
+
const mode = this.mode;
|
|
268
|
+
if (this._renderedMode === mode) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
this._renderedMode = mode;
|
|
272
|
+
this.shadowRoot.adoptedStyleSheets = [getStyleSheet()];
|
|
273
|
+
this.shadowRoot.innerHTML = mode === 'circular' ? CIRCULAR_HTML : LINEAR_HTML;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!customElements.get('progress-bar')) {
|
|
278
|
+
customElements.define('progress-bar', ProgressBar);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export default ProgressBar;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/* turbo-frame is inline by default; make it and the wrapper block-level so the
|
|
2
|
+
media box (and the progress bar centered on it) coincides with the parent
|
|
3
|
+
figure box, instead of sitting in a taller line box from the baseline gap --
|
|
4
|
+
otherwise an overlay centered on the figure lands off-center from the ring. */
|
|
5
|
+
turbo-frame:has(> .async-variant-processing),
|
|
6
|
+
turbo-frame:has(> .async-variant-failed) {
|
|
7
|
+
display: block;
|
|
8
|
+
}
|
|
9
|
+
.async-variant-processing,
|
|
10
|
+
.async-variant-failed {
|
|
11
|
+
position: relative;
|
|
12
|
+
display: block;
|
|
13
|
+
}
|
|
14
|
+
/* Present to reserve the variant's box, but rendered invisible via opacity
|
|
15
|
+
rather than visibility/display -- so it still has layout and stays queryable
|
|
16
|
+
by Capybara. The progress bar floats over it. block drops the baseline
|
|
17
|
+
descender so the ring centers on the box, not a few px below it. */
|
|
18
|
+
.async-variant-processing img,
|
|
19
|
+
.async-variant-processing video,
|
|
20
|
+
.async-variant-failed img,
|
|
21
|
+
.async-variant-failed video {
|
|
22
|
+
opacity: 0.001;
|
|
23
|
+
display: block;
|
|
24
|
+
}
|
|
25
|
+
.async-variant-progress {
|
|
26
|
+
position: absolute;
|
|
27
|
+
top: 50%;
|
|
28
|
+
left: 50%;
|
|
29
|
+
transform: translate(-50%, -50%);
|
|
30
|
+
--circular-size: 80px;
|
|
31
|
+
--progress-color: #2E7D32;
|
|
32
|
+
--indeterminate-color: #2E7D32;
|
|
33
|
+
--track-color: rgba(0, 0, 0, 0.25);
|
|
34
|
+
--error-color: #ff7c81;
|
|
35
|
+
}
|
|
@@ -11,6 +11,10 @@ module ActiveStorage
|
|
|
11
11
|
when "success"
|
|
12
12
|
variant_record.update!(state: "processed")
|
|
13
13
|
apply_reported_metadata(variant_record, params)
|
|
14
|
+
when "progress"
|
|
15
|
+
ActiveStorage::VariantRecord
|
|
16
|
+
.where(id: variant_record.id, state: %w[pending processing])
|
|
17
|
+
.update_all(progress: params[:percent].to_i.clamp(0, 100), last_heartbeat_at: Time.current)
|
|
14
18
|
when "failed"
|
|
15
19
|
# error column is TEXT (64KB); utf8mb4 is up to 4 bytes/char, so cap at 16k chars.
|
|
16
20
|
variant_record.update!(state: "failed", error: params[:error].to_s.truncate(16_000))
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
<div class="async-variant-state async-variant-failed">
|
|
2
|
-
<%=
|
|
2
|
+
<%= async_variant_placeholder_tag(variant, html_options, kind: kind) %>
|
|
3
|
+
|
|
4
|
+
<%= content_tag "progress-bar", "", mode: "circular", error: "", class: "async-variant-progress" %>
|
|
5
|
+
|
|
6
|
+
<%= ActiveStorage::AsyncVariants.stylesheet_link_tag "progress" %>
|
|
7
|
+
<%= ActiveStorage::AsyncVariants.javascript_include_tag "progress-bar", type: "module" %>
|
|
3
8
|
|
|
4
9
|
<% if ActiveStorage::AsyncVariants.retry_visible?(self) %>
|
|
5
10
|
<% dialog_id = "#{async_variant_frame_id(variant)}-error" %>
|
|
6
11
|
<% form_id = "#{dialog_id}-form" %>
|
|
7
12
|
<%# Light DOM, so the custom property inherits into the shadow root. %>
|
|
8
13
|
<style>
|
|
9
|
-
.async-variant-failed {
|
|
10
|
-
position: relative;
|
|
11
|
-
display: inline-block;
|
|
12
|
-
--retry-opener-display: none;
|
|
13
|
-
}
|
|
14
|
+
.async-variant-failed { --retry-opener-display: none; }
|
|
14
15
|
.async-variant-failed:hover { --retry-opener-display: inline-flex; }
|
|
15
16
|
</style>
|
|
16
17
|
<%# In the light DOM so its submit reaches Turbo, and so preventDefault on the
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
<div class="async-variant-state async-variant-processing">
|
|
2
|
-
<%=
|
|
2
|
+
<%= async_variant_placeholder_tag(variant, html_options, kind: kind) %>
|
|
3
|
+
|
|
4
|
+
<%= content_tag "progress-bar", "", mode: "circular", class: "async-variant-progress", percent: variant.progress %>
|
|
3
5
|
</div>
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
|
|
7
|
+
<%= ActiveStorage::AsyncVariants.stylesheet_link_tag "progress" %>
|
|
8
|
+
<%= ActiveStorage::AsyncVariants.javascript_include_tag "progress-bar", type: "module" %>
|
|
9
|
+
<% interval_ms = ActiveStorage::AsyncVariants.heartbeat_interval.in_seconds * 1000 %>
|
|
7
10
|
<script>
|
|
8
|
-
|
|
11
|
+
(() => {
|
|
12
|
+
const frame = document.currentScript?.closest("turbo-frame")
|
|
13
|
+
setTimeout(() => frame?.reload(), <%= interval_ms %>)
|
|
14
|
+
})()
|
|
9
15
|
</script>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddHeartbeatColumnsToVariantRecords < ActiveRecord::Migration[7.2]
|
|
4
|
+
def change
|
|
5
|
+
add_column :active_storage_variant_records, :progress, :integer, if_not_exists: true
|
|
6
|
+
add_column :active_storage_variant_records, :last_heartbeat_at, :datetime, if_not_exists: true
|
|
7
|
+
end
|
|
8
|
+
end
|
|
@@ -13,6 +13,23 @@ Feature: image_tag with async: true emits the right markup per variant state
|
|
|
13
13
|
Then the page should contain a turbo-frame
|
|
14
14
|
And the frame should render the processing state
|
|
15
15
|
|
|
16
|
+
Scenario: A processing variant renders an indeterminate progress bar before any heartbeat
|
|
17
|
+
Given a user with an attached avatar
|
|
18
|
+
And the avatar's :thumb_proc variant is in processing state
|
|
19
|
+
When I visit the avatar page for the :thumb_proc variant
|
|
20
|
+
Then the page should contain a turbo-frame
|
|
21
|
+
And the frame should render a progress bar
|
|
22
|
+
And the progress bar should be indeterminate
|
|
23
|
+
And the placeholder image should reserve layout
|
|
24
|
+
|
|
25
|
+
Scenario: A processing variant with reported progress renders a determinate progress bar
|
|
26
|
+
Given a user with an attached avatar
|
|
27
|
+
And the avatar's :thumb_proc variant is in processing state
|
|
28
|
+
And the avatar's :thumb_proc variant has reported 42% progress
|
|
29
|
+
When I visit the avatar page for the :thumb_proc variant
|
|
30
|
+
Then the page should contain a turbo-frame
|
|
31
|
+
And the progress bar should show 42%
|
|
32
|
+
|
|
16
33
|
Scenario: A failed variant renders the failed partial inside the frame
|
|
17
34
|
Given a user with an attached avatar
|
|
18
35
|
And the retry affordance is visible to everyone
|
|
@@ -20,3 +37,4 @@ Feature: image_tag with async: true emits the right markup per variant state
|
|
|
20
37
|
When I visit the avatar page for the :thumb_proc variant
|
|
21
38
|
Then the page should contain a turbo-frame
|
|
22
39
|
And the frame should render the failed state
|
|
40
|
+
And the frame should render an error progress bar
|
|
@@ -14,6 +14,34 @@ Given /^the avatar's :(\w+) variant is in (pending|processing|processed|failed)
|
|
|
14
14
|
end
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
Given /^the avatar's :(\w+) variant has reported (\d+)% progress$/ do |variant_name, percent|
|
|
18
|
+
variant = @user.avatar.variant(variant_name.to_sym)
|
|
19
|
+
record = variant.blob.variant_records.find_by(variation_digest: variant.variation.digest)
|
|
20
|
+
record.update!(progress: percent.to_i)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
Then /^the frame should render a progress bar$/ do
|
|
24
|
+
expect(page).to have_css("turbo-frame progress-bar", visible: :all)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
Then /^the frame should render an error progress bar$/ do
|
|
28
|
+
expect(page).to have_css("turbo-frame progress-bar[error]", visible: :all)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
Then /^the progress bar should be indeterminate$/ do
|
|
32
|
+
expect(page).to have_css("turbo-frame progress-bar:not([percent])", visible: :all)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Then /^the progress bar should show (\d+)%$/ do |percent|
|
|
36
|
+
expect(page).to have_css(%(turbo-frame progress-bar[percent="#{percent}"]), visible: :all)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Default (visible-only) matcher: passes only because the placeholder is hidden
|
|
40
|
+
# via opacity (not visibility/display), so Capybara still sees it.
|
|
41
|
+
Then /^the placeholder image should reserve layout$/ do
|
|
42
|
+
expect(page).to have_css("turbo-frame .async-variant-processing img")
|
|
43
|
+
end
|
|
44
|
+
|
|
17
45
|
Given /^the retry affordance is visible to everyone$/ do
|
|
18
46
|
ActiveStorage::AsyncVariants.retry_visible_if { true }
|
|
19
47
|
end
|
|
@@ -14,7 +14,7 @@ module ActiveStorage
|
|
|
14
14
|
return unless blob.bucket_backed?
|
|
15
15
|
|
|
16
16
|
named_variants.each do |name, named_variant|
|
|
17
|
-
next unless named_variant.transformations.key?(:
|
|
17
|
+
next unless named_variant.transformations.key?(:async)
|
|
18
18
|
|
|
19
19
|
ActiveStorage::AsyncVariants::ProcessJob.perform_later(
|
|
20
20
|
record, self.name, name.to_s,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module AsyncVariants
|
|
5
|
+
# External transforms free the worker at `initiate` and report progress via
|
|
6
|
+
# heartbeats; if the service dies, no terminal callback arrives and the
|
|
7
|
+
# record would sit in "processing" forever. This flips it to "failed" once
|
|
8
|
+
# the heartbeats go stale, re-arming itself each tick until then.
|
|
9
|
+
class HeartbeatWatchdogJob < ActiveJob::Base
|
|
10
|
+
# A retry destroys the record, so a re-armed job has nothing left to watch.
|
|
11
|
+
discard_on ActiveJob::DeserializationError
|
|
12
|
+
|
|
13
|
+
def perform(variant_record)
|
|
14
|
+
stale_after = ActiveStorage::AsyncVariants.heartbeat_stale_after
|
|
15
|
+
active = ActiveStorage::VariantRecord.where(id: variant_record.id, state: %w[pending processing])
|
|
16
|
+
stale = active.where("last_heartbeat_at < ?", Time.current - stale_after)
|
|
17
|
+
|
|
18
|
+
# Atomic check-and-set: a success/progress callback landing in the same
|
|
19
|
+
# instant moves the row out of `stale` and wins, instead of being clobbered.
|
|
20
|
+
marked_failed_count = stale.update_all(
|
|
21
|
+
state: "failed",
|
|
22
|
+
error: "Transcoding stalled: no heartbeat for over #{stale_after.to_i}s",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if marked_failed_count.positive?
|
|
26
|
+
touch_consumers(variant_record)
|
|
27
|
+
elsif active.any?
|
|
28
|
+
self.class.set(wait: stale_after).perform_later(variant_record)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# update_all skips the terminal-state touch, so invalidate consumer caches by hand.
|
|
35
|
+
def touch_consumers(variant_record)
|
|
36
|
+
variant_record.blob.attachments.includes(:record).each { |a| a.record&.touch }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -7,6 +7,25 @@ module ActiveStorage
|
|
|
7
7
|
# `helper ActiveStorage::AsyncVariants::Helper` (so the state partials can
|
|
8
8
|
# use them).
|
|
9
9
|
module Helper
|
|
10
|
+
# 1x1 transparent GIF: reserves the variant's box without fetching the
|
|
11
|
+
# original through the representations path on every poll.
|
|
12
|
+
PLACEHOLDER_SRC = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
|
13
|
+
|
|
14
|
+
def async_variant_placeholder_tag(variant, html_options = {}, kind: :image)
|
|
15
|
+
opts = async_variant_box_dimensions(variant)
|
|
16
|
+
.merge(html_options.symbolize_keys.except(:src, :controls, :autoplay, :preload, :poster))
|
|
17
|
+
# A video placeholder carries no <source>, so it reserves the box
|
|
18
|
+
# without loading anything -- same zero-network intent as the GIF.
|
|
19
|
+
kind == :video ? content_tag(:video, "", opts) : image_tag(PLACEHOLDER_SRC, opts)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def async_variant_box_dimensions(variant)
|
|
23
|
+
resize = variant.variation.transformations
|
|
24
|
+
.values_at(:resize_to_limit, :resize_to_fit, :resize_to_fill)
|
|
25
|
+
.compact.first
|
|
26
|
+
resize.is_a?(Array) ? { width: resize[0], height: resize[1] } : {}
|
|
27
|
+
end
|
|
28
|
+
|
|
10
29
|
# In test with non-bucket-backed services, the gem defers to vanilla
|
|
11
30
|
# ActiveStorage (synchronous vips transform) -- inline rendering keeps
|
|
12
31
|
# those environments simple. Otherwise, only inline a normal <img> when
|
|
@@ -52,18 +52,30 @@ module ActiveStorage
|
|
|
52
52
|
find_preview_variant_record&.state || "pending"
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
def progress
|
|
56
|
+
find_preview_variant_record&.progress
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def progress_known?
|
|
60
|
+
!find_preview_variant_record&.progress.nil?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def last_heartbeat_at
|
|
64
|
+
find_preview_variant_record&.last_heartbeat_at
|
|
65
|
+
end
|
|
66
|
+
|
|
55
67
|
private
|
|
56
68
|
|
|
57
69
|
def async_preview?
|
|
58
|
-
resolved_async_options[:
|
|
70
|
+
resolved_async_options[:async].present?
|
|
59
71
|
end
|
|
60
72
|
|
|
61
73
|
# Variations rebuilt from the redirect URL only carry transformations --
|
|
62
|
-
# :
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
74
|
+
# :async / :transformer are stripped at Variation#initialize and not
|
|
75
|
+
# embedded in the URL key. Recover them via the digest-keyed registry that
|
|
76
|
+
# VariationExtension warms on every view-side variant call, or fall back to
|
|
77
|
+
# scanning attached named variants when the registry is cold (autoloader
|
|
78
|
+
# hasn't touched the consumer model yet).
|
|
67
79
|
def resolved_async_options
|
|
68
80
|
@resolved_async_options ||=
|
|
69
81
|
variation.async_options.presence ||
|
|
@@ -78,7 +90,7 @@ module ActiveStorage
|
|
|
78
90
|
attachment.send(:named_variants).each do |name, _|
|
|
79
91
|
candidate = attachment.variant(name.to_sym)
|
|
80
92
|
next unless candidate.variation.transformations.to_json == target
|
|
81
|
-
return [attachment, name, candidate.variation.async_options] if candidate.variation.async_options[:
|
|
93
|
+
return [attachment, name, candidate.variation.async_options] if candidate.variation.async_options[:async]
|
|
82
94
|
end
|
|
83
95
|
end
|
|
84
96
|
nil
|
|
@@ -95,25 +107,9 @@ module ActiveStorage
|
|
|
95
107
|
blob.variant_records.find_by(variation_digest: variation.digest)
|
|
96
108
|
end
|
|
97
109
|
|
|
110
|
+
# Serve the original until the preview variant is processed.
|
|
98
111
|
def fallback_preview_url(...)
|
|
99
|
-
|
|
100
|
-
when :original then blob.url(...)
|
|
101
|
-
when :blank then nil
|
|
102
|
-
when Proc then active_fallback.call(blob)
|
|
103
|
-
when String then active_fallback
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def active_fallback
|
|
108
|
-
if failed?
|
|
109
|
-
resolved_async_options.fetch(:failed) { resolved_async_options[:processing] }
|
|
110
|
-
else
|
|
111
|
-
resolved_async_options[:processing]
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def failed?
|
|
116
|
-
find_preview_variant_record&.state == "failed"
|
|
112
|
+
blob.url(...)
|
|
117
113
|
end
|
|
118
114
|
end
|
|
119
115
|
end
|
|
@@ -79,6 +79,13 @@ module ActiveStorage
|
|
|
79
79
|
variant_record_id: variant_record.id,
|
|
80
80
|
**options,
|
|
81
81
|
)
|
|
82
|
+
|
|
83
|
+
# Seed the heartbeat so a transform that dies before its first heartbeat
|
|
84
|
+
# still goes stale, then arm the watchdog.
|
|
85
|
+
variant_record.touch(:last_heartbeat_at)
|
|
86
|
+
ActiveStorage::AsyncVariants::HeartbeatWatchdogJob
|
|
87
|
+
.set(wait: ActiveStorage::AsyncVariants.heartbeat_stale_after)
|
|
88
|
+
.perform_later(variant_record)
|
|
82
89
|
end
|
|
83
90
|
end
|
|
84
91
|
end
|
|
@@ -7,7 +7,7 @@ module ActiveStorage
|
|
|
7
7
|
#
|
|
8
8
|
# has_one_attached :avatar do |attachable|
|
|
9
9
|
# attachable.variant :thumb, resize_to_limit: [150, 150], format: "webp",
|
|
10
|
-
# transformer: Crucible,
|
|
10
|
+
# transformer: Crucible, async: true
|
|
11
11
|
# end
|
|
12
12
|
#
|
|
13
13
|
# Without this, the Registry only warms when view-side code calls
|
|
@@ -7,7 +7,7 @@ module ActiveStorage
|
|
|
7
7
|
# Process-wide lookup of async_options keyed by Variation#digest.
|
|
8
8
|
#
|
|
9
9
|
# Populated lazily by VariationExtension#initialize whenever a Variation is
|
|
10
|
-
# constructed with :
|
|
10
|
+
# constructed with :async in its async_options -- which happens on
|
|
11
11
|
# every view-side `attachment.variant(:name)` call. The redirect controller
|
|
12
12
|
# then resolves async_options by digest without scanning blob.attachments
|
|
13
13
|
# for a transformations-match.
|
|
@@ -13,8 +13,8 @@ module ActiveStorage
|
|
|
13
13
|
|
|
14
14
|
# processed and failed are terminal states: cache fragments built when
|
|
15
15
|
# state was pending/processing need to be invalidated so the next
|
|
16
|
-
# render sees the new state (
|
|
17
|
-
# swaps src to the direct CDN URL, etc.).
|
|
16
|
+
# render sees the new state (swaps the processing box for the failed
|
|
17
|
+
# error state, swaps src to the direct CDN URL, etc.).
|
|
18
18
|
def reached_terminal_state?
|
|
19
19
|
saved_change_to_state? && %w[processed failed].include?(state)
|
|
20
20
|
end
|
|
@@ -27,15 +27,8 @@ module ActiveStorage
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def url(...)
|
|
30
|
-
if blob.bucket_backed? && !processed?
|
|
31
|
-
|
|
32
|
-
case fallback
|
|
33
|
-
when :original then blob.url(...)
|
|
34
|
-
when :blank then nil
|
|
35
|
-
when Proc then fallback.call(blob)
|
|
36
|
-
when String then fallback
|
|
37
|
-
else super
|
|
38
|
-
end
|
|
30
|
+
if blob.bucket_backed? && async_variant? && !processed?
|
|
31
|
+
blob.url(...)
|
|
39
32
|
else
|
|
40
33
|
super
|
|
41
34
|
end
|
|
@@ -70,8 +63,24 @@ module ActiveStorage
|
|
|
70
63
|
async_record&.state || "pending"
|
|
71
64
|
end
|
|
72
65
|
|
|
66
|
+
def progress
|
|
67
|
+
async_record&.progress
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def progress_known?
|
|
71
|
+
!async_record&.progress.nil?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def last_heartbeat_at
|
|
75
|
+
async_record&.last_heartbeat_at
|
|
76
|
+
end
|
|
77
|
+
|
|
73
78
|
private
|
|
74
79
|
|
|
80
|
+
def async_variant?
|
|
81
|
+
resolved_async_options[:async].present?
|
|
82
|
+
end
|
|
83
|
+
|
|
75
84
|
def resolved_async_options
|
|
76
85
|
@resolved_async_options ||=
|
|
77
86
|
variation.async_options.presence ||
|
|
@@ -80,14 +89,6 @@ module ActiveStorage
|
|
|
80
89
|
{}
|
|
81
90
|
end
|
|
82
91
|
|
|
83
|
-
def active_fallback
|
|
84
|
-
if failed?
|
|
85
|
-
resolved_async_options.fetch(:failed) { resolved_async_options[:processing] }
|
|
86
|
-
else
|
|
87
|
-
resolved_async_options[:processing]
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
|
|
91
92
|
# Cold-path scan: used by enqueue! (which needs the attachment +
|
|
92
93
|
# variant_name to dispatch ProcessJob) and by resolved_async_options as
|
|
93
94
|
# a fallback when the Registry is cold (e.g. in dev, when a
|
|
@@ -114,7 +115,7 @@ module ActiveStorage
|
|
|
114
115
|
attachment.send(:named_variants).each do |name, _|
|
|
115
116
|
candidate = attachment.variant(name.to_sym)
|
|
116
117
|
if candidate.variation.transformations.to_json == target
|
|
117
|
-
return [attachment, name, candidate.variation.async_options] if candidate.variation.async_options[:
|
|
118
|
+
return [attachment, name, candidate.variation.async_options] if candidate.variation.async_options[:async]
|
|
118
119
|
end
|
|
119
120
|
end
|
|
120
121
|
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module ActiveStorage
|
|
4
4
|
module AsyncVariants
|
|
5
5
|
module VariationExtension
|
|
6
|
-
ASYNC_KEYS = %i[
|
|
6
|
+
ASYNC_KEYS = %i[async transformer].freeze
|
|
7
7
|
|
|
8
8
|
def initialize(transformations)
|
|
9
9
|
if transformations.is_a?(Hash)
|
|
@@ -13,7 +13,7 @@ module ActiveStorage
|
|
|
13
13
|
@async_options = {}
|
|
14
14
|
super
|
|
15
15
|
end
|
|
16
|
-
ActiveStorage::AsyncVariants::Registry.register(digest, @async_options) if @async_options[:
|
|
16
|
+
ActiveStorage::AsyncVariants::Registry.register(digest, @async_options) if @async_options[:async]
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def async_options
|
|
@@ -14,6 +14,7 @@ require_relative "async_variants/preview_extension"
|
|
|
14
14
|
require_relative "async_variants/attachment_extension"
|
|
15
15
|
require_relative "async_variants/reflection_extension"
|
|
16
16
|
require_relative "async_variants/process_job"
|
|
17
|
+
require_relative "async_variants/heartbeat_watchdog_job"
|
|
17
18
|
require_relative "async_variants/asset_tag_helper_extension"
|
|
18
19
|
|
|
19
20
|
module ActiveStorage
|
|
@@ -30,6 +31,14 @@ module ActiveStorage
|
|
|
30
31
|
# the host's class is autoloadable. Defaults to ActionController::Base.
|
|
31
32
|
mattr_accessor :parent_controller, default: "ActionController::Base"
|
|
32
33
|
|
|
34
|
+
# How long an external transform may go without a heartbeat before the
|
|
35
|
+
# watchdog fails it. Must exceed the transformer's heartbeat interval.
|
|
36
|
+
mattr_accessor :heartbeat_stale_after, default: 60.seconds
|
|
37
|
+
|
|
38
|
+
# The transformer's heartbeat cadence. The turbo-frame poll matches it so
|
|
39
|
+
# each reload lands on fresh progress; keep heartbeat_stale_after well above it.
|
|
40
|
+
mattr_accessor :heartbeat_interval, default: 5.seconds
|
|
41
|
+
|
|
33
42
|
# Gates the failed-state retry affordance; the block runs in the view context.
|
|
34
43
|
def self.retry_visible_if(&block)
|
|
35
44
|
self.retry_visible_proc = block
|
|
@@ -41,6 +50,10 @@ module ActiveStorage
|
|
|
41
50
|
false
|
|
42
51
|
end
|
|
43
52
|
|
|
53
|
+
def self.configure
|
|
54
|
+
yield self
|
|
55
|
+
end
|
|
56
|
+
|
|
44
57
|
class Engine < ::Rails::Engine
|
|
45
58
|
# Prepend the core model/reflection extensions before eager_load runs
|
|
46
59
|
# so that models' has_X_attached blocks (and the Variation.wrap calls
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_storage-async_variants
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Micah Geisel
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-06-
|
|
10
|
+
date: 2026-06-08 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: activestorage
|
|
@@ -66,7 +66,9 @@ files:
|
|
|
66
66
|
- LICENSE.txt
|
|
67
67
|
- README.md
|
|
68
68
|
- Rakefile
|
|
69
|
+
- app/assets/javascripts/progress-bar.js
|
|
69
70
|
- app/assets/javascripts/retry.js
|
|
71
|
+
- app/assets/stylesheets/progress.css
|
|
70
72
|
- app/assets/stylesheets/retry.css
|
|
71
73
|
- app/controllers/active_storage/async_variants/callbacks_controller.rb
|
|
72
74
|
- app/controllers/active_storage/async_variants/states_controller.rb
|
|
@@ -76,6 +78,7 @@ files:
|
|
|
76
78
|
- app/views/active_storage/async_variants/states/_processing.html.erb
|
|
77
79
|
- app/views/active_storage/async_variants/states/show.html.erb
|
|
78
80
|
- config/routes.rb
|
|
81
|
+
- db/migrate/20260607000001_add_heartbeat_columns_to_variant_records.rb
|
|
79
82
|
- features/retry_flow.feature
|
|
80
83
|
- features/retry_visibility.feature
|
|
81
84
|
- features/state_rendering.feature
|
|
@@ -88,6 +91,7 @@ files:
|
|
|
88
91
|
- lib/active_storage/async_variants/asset_tag_helper_extension.rb
|
|
89
92
|
- lib/active_storage/async_variants/attachment_extension.rb
|
|
90
93
|
- lib/active_storage/async_variants/blob_extension.rb
|
|
94
|
+
- lib/active_storage/async_variants/heartbeat_watchdog_job.rb
|
|
91
95
|
- lib/active_storage/async_variants/helper.rb
|
|
92
96
|
- lib/active_storage/async_variants/preview_extension.rb
|
|
93
97
|
- lib/active_storage/async_variants/process_job.rb
|