active_storage-async_variants 0.5.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bc926d64601c238db11edd005f220c0842cbf4316f405d43c23d08a99ff874de
4
- data.tar.gz: 177a3d4a3647e41ac5e23db1908265c556a8c4c006b5497cbb802b2cf9ebb02e
3
+ metadata.gz: f3d828692f86ff7b409b93f3198daae20c5c6821a0cce10da31b625abe7c04be
4
+ data.tar.gz: '0319ea7873525ea5d76a930e195687b3ba28bebd58922e3fc4aa821238241f28'
5
5
  SHA512:
6
- metadata.gz: 25f4d2430ce2547c9a4109bd648f338914e92cacdb38bf2e651cdfff21a9a045e90480c10e6170f0723dbcb5e675a35370cda7c91ced43ec41c5bfa3f112f2a1
7
- data.tar.gz: c02fac50058fd3465435bf194e09ffb0732deda2c61316b8e960b1179e4ee8e9eb417e1dd192fdf564c0e059d87a674fe76aa852f99a385592ddfcd71fb1a453
6
+ metadata.gz: 9e80ebc6059bf84853cf7bf05d824ed5d25d5b195a4702d86495cdb29366eb02ee390558768fa66a1f5ea85e8e86987a46f9c7a2492de59e1a7287b25ef7074a
7
+ data.tar.gz: 23dc024c9a1ceaa334580d32af52f20e4e327f3d40d22e438616bdc0077f5b62f95deb50670ee5a30b3a1e72716e2ef9604bd6eabe2fc3a699aaa21142518ad8
data/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## [0.6.0]
2
+
3
+ - 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.
4
+ - 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.
5
+ - 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.
6
+ - Added an `ActiveStorage::AsyncVariants.configure { |config| … }` block for setting all options in one place (see the README's Configuration section).
7
+ - Includes a migration adding the `progress` and `last_heartbeat_at` columns; run it before deploying.
8
+
1
9
  ## [0.5.0]
2
10
 
3
11
  - 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/README.md CHANGED
@@ -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).
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,269 @@
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
+ :host([error]) .progress-ring {
102
+ stroke: var(--error-color);
103
+ }
104
+
105
+ .label {
106
+ position: absolute;
107
+ inset: 0;
108
+ display: flex;
109
+ align-items: center;
110
+ justify-content: center;
111
+ color: #fff;
112
+ mix-blend-mode: difference;
113
+ }
114
+
115
+ /* When the host carries its own inline styling (e.g. a color override), opt
116
+ out of the blend trick and just inherit, so the override wins predictably. */
117
+ :host([style]) .label {
118
+ mix-blend-mode: initial;
119
+ color: inherit;
120
+ }
121
+
122
+ /* Indeterminate (circular): no percent set — spin a fixed arc around the ring. */
123
+ :host(:not([percent])) .ring {
124
+ animation: indeterminate-rotate var(--indeterminate-duration) infinite linear;
125
+ }
126
+
127
+ :host(:not([percent])) .progress-ring {
128
+ stroke-dashoffset: ${PATH_LENGTH * 0.75};
129
+ }
130
+
131
+ @keyframes indeterminate-rotate {
132
+ 0% { transform: rotate(-90deg); }
133
+ 100% { transform: rotate(270deg); }
134
+ }
135
+ `;
136
+
137
+ const LINEAR_HTML = `
138
+ <div class="bar"></div>
139
+ <div class="text"><slot></slot></div>
140
+ `;
141
+
142
+ const CIRCULAR_HTML = `
143
+ <div class="circular">
144
+ <svg class="ring" viewBox="0 0 100 100">
145
+ <circle class="track" cx="50" cy="50" r="${CIRCLE_RADIUS}"></circle>
146
+ <circle class="progress-ring" cx="50" cy="50" r="${CIRCLE_RADIUS}" pathLength="${PATH_LENGTH}"></circle>
147
+ </svg>
148
+ <span class="label"><slot></slot></span>
149
+ </div>
150
+ `;
151
+
152
+ let styleSheet = null;
153
+ function getStyleSheet() {
154
+ if (!styleSheet) {
155
+ styleSheet = new CSSStyleSheet();
156
+ styleSheet.replaceSync(STYLES);
157
+ }
158
+ return styleSheet;
159
+ }
160
+
161
+ function clampPercent(value) {
162
+ const number = Number(value);
163
+ if (Number.isNaN(number)) {
164
+ throw new TypeError(`progress-bar: percent must be numeric, got ${JSON.stringify(value)}`);
165
+ }
166
+ return Math.min(100, Math.max(0, number));
167
+ }
168
+
169
+ class ProgressBar extends HTMLElement {
170
+ constructor() {
171
+ super();
172
+ this.attachShadow({ mode: 'open' });
173
+ this._percent = null;
174
+ this._renderedMode = null;
175
+ }
176
+
177
+ connectedCallback() {
178
+ this.render();
179
+ this.setAttribute('role', 'progressbar');
180
+ this.setAttribute('aria-valuemin', '0');
181
+ this.setAttribute('aria-valuemax', '100');
182
+ this.updateBar();
183
+ }
184
+
185
+ get percent() {
186
+ return this._percent;
187
+ }
188
+
189
+ set percent(value) {
190
+ if (value === null || value === undefined) {
191
+ this.removeAttribute('percent');
192
+ } else {
193
+ this.setAttribute('percent', clampPercent(value));
194
+ }
195
+ }
196
+
197
+ get error() {
198
+ return this.hasAttribute('error');
199
+ }
200
+
201
+ set error(value) {
202
+ this.toggleAttribute('error', Boolean(value));
203
+ }
204
+
205
+ get indeterminate() {
206
+ return !this.hasAttribute('percent');
207
+ }
208
+
209
+ get mode() {
210
+ return this.getAttribute('mode') === 'circular' ? 'circular' : 'linear';
211
+ }
212
+
213
+ set mode(value) {
214
+ this.setAttribute('mode', value);
215
+ }
216
+
217
+ static get observedAttributes() {
218
+ return ['percent', 'mode'];
219
+ }
220
+
221
+ attributeChangedCallback(name, oldValue, newValue) {
222
+ if (name === 'percent') {
223
+ this._percent = newValue === null ? null : clampPercent(newValue);
224
+ }
225
+ if (name === 'mode') {
226
+ this.render();
227
+ }
228
+ this.updateBar();
229
+ }
230
+
231
+ updateBar() {
232
+ if (this.mode === 'circular') {
233
+ const ring = this.shadowRoot?.querySelector('.progress-ring');
234
+ if (ring) {
235
+ ring.style.strokeDashoffset = this.indeterminate
236
+ ? ''
237
+ : String(PATH_LENGTH * (1 - this._percent / 100));
238
+ }
239
+ } else {
240
+ const bar = this.shadowRoot?.querySelector('.bar');
241
+ if (bar) {
242
+ // Leave width to the indeterminate CSS animation when unknown.
243
+ bar.style.width = this.indeterminate ? '' : `${this._percent}%`;
244
+ }
245
+ }
246
+
247
+ if (this.indeterminate) {
248
+ this.removeAttribute('aria-valuenow');
249
+ } else {
250
+ this.setAttribute('aria-valuenow', String(this._percent));
251
+ }
252
+ }
253
+
254
+ render() {
255
+ const mode = this.mode;
256
+ if (this._renderedMode === mode) {
257
+ return;
258
+ }
259
+ this._renderedMode = mode;
260
+ this.shadowRoot.adoptedStyleSheets = [getStyleSheet()];
261
+ this.shadowRoot.innerHTML = mode === 'circular' ? CIRCULAR_HTML : LINEAR_HTML;
262
+ }
263
+ }
264
+
265
+ if (!customElements.get('progress-bar')) {
266
+ customElements.define('progress-bar', ProgressBar);
267
+ }
268
+
269
+ export default ProgressBar;
@@ -0,0 +1,30 @@
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
+ display: block;
7
+ }
8
+ .async-variant-processing {
9
+ position: relative;
10
+ display: block;
11
+ }
12
+ /* Present to reserve the processed media's box, but rendered invisible via
13
+ opacity rather than visibility/display -- so it still has layout and stays
14
+ queryable by Capybara. The spinner floats over it. block drops the baseline
15
+ descender so the ring centers on the media, not a few px below it. */
16
+ .async-variant-processing img,
17
+ .async-variant-processing video {
18
+ opacity: 0.001;
19
+ display: block;
20
+ }
21
+ .async-variant-progress {
22
+ position: absolute;
23
+ top: 50%;
24
+ left: 50%;
25
+ transform: translate(-50%, -50%);
26
+ --circular-size: 80px;
27
+ --progress-color: #2E7D32;
28
+ --indeterminate-color: #2E7D32;
29
+ --track-color: rgba(0, 0, 0, 0.25);
30
+ }
@@ -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,9 +1,16 @@
1
1
  <div class="async-variant-state async-variant-processing">
2
- <%= image_tag async_variant_representation_path(variant), **html_options.symbolize_keys.except(:controls, :autoplay, :preload) %>
2
+ <% method = kind == :video ? :video_tag : :image_tag %>
3
+ <%= send method, async_variant_representation_path(variant), **html_options.symbolize_keys %>
4
+
5
+ <%= content_tag "progress-bar", "", mode: "circular", class: "async-variant-progress", percent: variant.progress %>
3
6
  </div>
4
- <%# Self-perpetuating poll: Turbo runs <script>s in frame swaps, so each
5
- pending/processing response schedules its own next reload. Terminal
6
- partials (_failed, _processed) emit no script -> chain stops. %>
7
+
8
+ <%= ActiveStorage::AsyncVariants.stylesheet_link_tag "progress" %>
9
+ <%= ActiveStorage::AsyncVariants.javascript_include_tag "progress-bar", type: "module" %>
10
+ <% interval_ms = ActiveStorage::AsyncVariants.heartbeat_interval.in_seconds * 1000 %>
7
11
  <script>
8
- setTimeout(() => document.getElementById("<%= async_variant_frame_id(variant) %>")?.reload(), 3000)
12
+ (() => {
13
+ const frame = document.currentScript?.closest("turbo-frame")
14
+ setTimeout(() => frame?.reload(), <%= interval_ms %>)
15
+ })()
9
16
  </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
@@ -14,6 +14,30 @@ 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 progress bar should be indeterminate$/ do
28
+ expect(page).to have_css("turbo-frame progress-bar:not([percent])", visible: :all)
29
+ end
30
+
31
+ Then /^the progress bar should show (\d+)%$/ do |percent|
32
+ expect(page).to have_css(%(turbo-frame progress-bar[percent="#{percent}"]), visible: :all)
33
+ end
34
+
35
+ # Default (visible-only) matcher: passes only because the placeholder is hidden
36
+ # via opacity (not visibility/display), so Capybara still sees it.
37
+ Then /^the placeholder image should reserve layout$/ do
38
+ expect(page).to have_css("turbo-frame .async-variant-processing img")
39
+ end
40
+
17
41
  Given /^the retry affordance is visible to everyone$/ do
18
42
  ActiveStorage::AsyncVariants.retry_visible_if { true }
19
43
  end
@@ -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
@@ -52,6 +52,18 @@ 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?
@@ -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
@@ -70,6 +70,18 @@ module ActiveStorage
70
70
  async_record&.state || "pending"
71
71
  end
72
72
 
73
+ def progress
74
+ async_record&.progress
75
+ end
76
+
77
+ def progress_known?
78
+ !async_record&.progress.nil?
79
+ end
80
+
81
+ def last_heartbeat_at
82
+ async_record&.last_heartbeat_at
83
+ end
84
+
73
85
  private
74
86
 
75
87
  def resolved_async_options
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveStorage
4
4
  module AsyncVariants
5
- VERSION = "0.5.0"
5
+ VERSION = "0.6.0"
6
6
  end
7
7
  end
@@ -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,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_storage-async_variants
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Micah Geisel
@@ -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