active_storage-async_variants 0.9.0 → 0.10.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: d60acebdeb8fe9482d73576e79abb4737fb052e6df939e09829b9c0e766b9e12
4
- data.tar.gz: 68be3b6ef1f9cb34e5a08f87b20470520367e16fdcc81ec73f18d5956ab8979a
3
+ metadata.gz: fffff13cd7b602bc0139ae60dff32c4d38d573b738edaa244f21df5dfa331723
4
+ data.tar.gz: 1251c74b70fd15eaaf1e48e494b7fc9d773f2989f83e1b99ca6862fdaec6c144
5
5
  SHA512:
6
- metadata.gz: a3fc174236833deeda757d08b0f8ff68769d5130beb7b3f20fd68082e73aa72ac2a088fd23fad45209cd9849caa9d0293ec5f9f11bd365dec3e6d995a5b79fb0
7
- data.tar.gz: 7944a7832431949722581b13f62c99b785df7abfaf7693ea236213df1a08e730d9070bce1465ea32255b29423dc393d30bfd61f9b72cf2bdc39b48010bcd806d
6
+ metadata.gz: 2bfef2ba7b3d4576d20a867095c7e75f8f36ab122d0718901856ce0bab8330521f5edeb73a0e75a12f3ed20250fe28c25304b4823929167daa910d240a7b9477
7
+ data.tar.gz: f0e5e7a9cfe3d75ff7afe960fc7675447b466dc6e8500b7db6fca3c6a24cd8d0dfa837e938b216898a8ee8d95f470c1aa80b03be7c407e8e0ee7a63a46d74775
data/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ ## [0.10.0]
2
+
3
+ - **Breaking:** Async previews now persist the same graph stock Active Storage builds. For a previewable blob (a video), an image-format variant is a `Preview`: the extracted frame is attached as the source blob's `preview_image`, and the variant record hangs off the *frame* blob, not the source. `ProcessJob` resolves the attachment's `representation` and takes the preview path when stock would. External transformers receive a frame placeholder (`byte_size: 0, checksum: "0"`) to write into; the success callback reconciles both placeholders. Read video stills through `representation`/`preview`, not `variant`.
4
+ - **Breaking:** Naming a `transformer:` is now the only async opt-in. The `async:` option is gone and raises `ArgumentError`. The built-in `ActiveStorage::AsyncVariants::Standard` transformer replaces a bare `async: true` (the stock variant pipeline, run in the background). A transformer's kind follows from what it overrides: `Transformer#standard?` (neither), `#inline?` (`process`), `#external?` (`initiate`).
5
+ - **Breaking:** Attaching a file enqueues an async variant only when it declares Active Storage's `preprocessed:` option (`true`, a method name, or a proc evaluated against the record). Previously every variant naming a `transformer:` was enqueued on every attach, whatever the file — so an image upload also ran the attachment's video transcode. Stock Active Storage no longer sees async variants when preprocessing, so `preprocessed: true` never also queues a `TransformJob` or a local `PreviewImageJob`.
6
+ - Migration note: existing variant records for video stills live on the source blob under the old structure. Move them onto the frame blob (`UPDATE active_storage_variant_records SET blob_id = <frame>`), attaching a placeholder frame where none exists; the `variation_digest` is unchanged. Records whose output is itself a video (transcodes) stay on the source. Replace `async: true` with `transformer: ActiveStorage::AsyncVariants::Standard` where no transformer was named, and drop it where one was. Add `preprocessed: true` (or a condition) to each async variant that should process on attach.
7
+ - `Preview` speaks the same progress protocol as `VariantWithRecord`: `async_state`, `progress`, `progress_known?`, `progress_rate`, `last_heartbeat_at`, `error`.
8
+ - `async_record` is public on both `VariantWithRecord` and `Preview`, and both gain `retry!` (drop a failed record, then `enqueue!`), so callers need not know where a representation keeps its record.
9
+ - `NamedVariantScan` compares declarations structurally instead of instantiating `attachment.variant`, which raises for videos.
10
+ - Fix: declaring `preprocessed:` alongside a `transformer:` no longer breaks the declaration-time registry warm-up (a proc raised on `Marshal.dump`; other values registered the wrong digest).
11
+ - MirrorService: the success callback mirrors reconciled variant blobs to the mirrors, as Rails does for direct uploads, and `bucket_backed?` resolves through the primary service.
12
+ - Frame placeholder creation is serialized on the blob row: every image variant of one video reaches it concurrently, and a second `preview_image` attachment would be invisible to `has_one`, stranding the records hanging off it.
13
+ - Ships the `state`/`error`/`attempts` migration for `active_storage_variant_records` (guarded with `if_not_exists`, so apps that added the columns themselves can install it harmlessly), and builds the test schema from `db/migrate` so a missing shipped migration fails the suite.
14
+ - Fix: `ProcessJob` skips a variant record that is already processed or whose run is still heartbeating. Previously every enqueue re-dispatched the variant — attaching an already-processed blob to another record, a retry, or a duplicate job paid for a second render and flipped processed media back to processing.
15
+ - Fix: the success callback applies the reported `byte_size` and `checksum` whenever they arrive, not only over the `0`/`"0"` placeholders, since a re-render writes over the record's existing output blob. Reported `preview_image_*` values land only on a frame, never on a variant's source blob.
16
+ - Fix: destroying a variant record purges any output a racing render attached after the destroy read its attachments, so a purged source no longer leaves outputs pointing at a deleted record.
17
+
1
18
  ## [0.9.0]
2
19
 
3
20
  - **Breaking:** Split the opinionated UI out into a separate gem, [`active_storage-async_variants-ui`](https://github.com/botandrose/active_storage-async_variants-ui). This gem is now UI-free plumbing — the async state machine, `ProcessJob`, the heartbeat watchdog, the external-service callback endpoint, and the `Transformer` base class. Its only runtime dependency is `activestorage` (no more `turbo-rails` / `isolate_assets`).
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 `async: true` option on a variant definition opts it into async processing.
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. Naming a `transformer:` on a variant definition opts it into async processing.
8
8
 
9
9
  ## Commands
10
10
 
@@ -19,17 +19,23 @@ 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 (`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`
22
+ - **`VariationExtension`** → `ActiveStorage::Variation` — extracts the `transformer:` option from variant config before passing the rest to standard Active Storage
23
+ - **`AttachmentExtension`** → `ActiveStorage::Attachment` — hooks into `transform_variants_later` to enqueue `ProcessJob` for async variants whose `preprocessed:` holds for the record, hiding async variants from stock preprocessing
24
24
  - **`VariantWithRecordExtension`** → `ActiveStorage::VariantWithRecord` — overrides URL generation to serve the original blob while not ready; adds state query methods (`processed?`, `processing?`, `pending?`, `failed?`)
25
- - **`ProcessJob`** — background job that determines transformer type (inline vs external) and processes accordingly
25
+ - **`PreviewExtension`** → `ActiveStorage::Preview` — blocks the synchronous preview transform for async variants and resolves state/URLs through the stock preview graph
26
+ - **`ProcessJob`** — background job that resolves the attachment's representation (variant vs preview), determines transformer type (inline vs external), and processes accordingly
27
+
28
+ ### Preview Structure
29
+
30
+ Async previews persist the same graph stock Active Storage builds: the extracted frame is attached as the source blob's `preview_image`, and the variant record hangs off the *frame* blob, not the source. External transformers get a frame placeholder (`byte_size: 0, checksum: "0"`) the service writes into; the success callback reconciles both the variant and frame placeholders. Inline/default transformers extract the real frame up front via the stock previewer.
26
31
 
27
32
  ### Transformer Types
28
33
 
29
- - **Inline**: implements `process(file, **options)` → blocks worker, returns `{ io:, content_type:, filename: }`
30
- - **External**: implements `initiate(source_url:, destination_url:, callback_url:, **options)` → frees worker immediately, external service POSTs to callback URL when done
34
+ Naming a `transformer:` is the async opt-in; the kind is determined by what the transformer overrides (`Transformer#standard?`/`#inline?`/`#external?`):
31
35
 
32
- The gem detects which type by checking if `process` is overridden on the transformer class.
36
+ - **Standard** (`ActiveStorage::AsyncVariants::Standard`): overrides neither → runs the stock Active Storage variant pipeline in the background. Replaces the old bare `async: true` (no transformer) declaration, which now raises.
37
+ - **Inline**: overrides `process(file, **options)` → blocks worker, returns `{ io:, content_type:, filename: }`
38
+ - **External**: overrides `initiate(source_url:, callback_url:, **options)` → frees worker immediately, external service POSTs to callback URL when done
33
39
 
34
40
  ### Callback Endpoint
35
41
 
data/README.md CHANGED
@@ -21,7 +21,7 @@ bin/rails db:migrate
21
21
 
22
22
  ## Usage
23
23
 
24
- Add `async: true` to any named variant to opt into the async pipeline:
24
+ Name a `transformer:` on any variant to opt it into the async pipeline:
25
25
 
26
26
  ```ruby
27
27
  class User < ApplicationRecord
@@ -29,33 +29,49 @@ class User < ApplicationRecord
29
29
  attachable.variant :web,
30
30
  transformer: VideoTranscoder,
31
31
  codec: "vp9",
32
- resolution: "720p",
33
- async: true
32
+ resolution: "720p"
34
33
  end
35
34
  end
36
35
  ```
37
36
 
38
- 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
+ The presence of a `transformer:` is what opts a variant into async processing. Without it, variants behave exactly as they do in standard Active Storage. The transformer also says *how* the work happens — there are three kinds, distinguished by what they implement:
39
38
 
40
39
  ```ruby
41
40
  has_one_attached :video do |attachable|
42
- # Async with custom transformer (video transcode)
41
+ # External: hands off to a remote service (implements #initiate)
43
42
  attachable.variant :web,
44
43
  transformer: VideoTranscoder,
45
- codec: "vp9",
46
- async: true
44
+ codec: "vp9"
47
45
 
48
- # Async with default transformer (large image resize that's too slow for inline)
46
+ # Standard: the stock Active Storage variant pipeline, run in the background
47
+ # (e.g. a large image resize that's too slow for an inline request)
49
48
  attachable.variant :thumbnail,
50
49
  resize_to_limit: [200, 200],
51
- async: true
50
+ transformer: ActiveStorage::AsyncVariants::Standard
52
51
 
53
- # Sync with custom transformer (fast custom processing, no opt-in needed)
52
+ # Inline: custom processing in the worker (implements #process)
54
53
  attachable.variant :watermarked,
55
54
  transformer: WatermarkStamper
56
55
  end
57
56
  ```
58
57
 
58
+ A variant with no `transformer:` is a plain synchronous Active Storage variant — the gem stays out of the way.
59
+
60
+ ### Processing on attach
61
+
62
+ An async variant is processed when a file is attached only if it says so with Active Storage's own `preprocessed:` option. It takes `true`, a method name, or a proc, evaluated against the record — so one attachment that accepts both images and videos can transcode only the videos:
63
+
64
+ ```ruby
65
+ has_one_attached :media do |attachable|
66
+ attachable.variant :thumb, resize_to_limit: [150, 150], format: "webp",
67
+ transformer: Crucible, preprocessed: true
68
+ attachable.variant :transcoded, resize_to_limit: [1280, 720], format: "mp4",
69
+ transformer: Crucible, preprocessed: :video?
70
+ end
71
+ ```
72
+
73
+ An async variant without `preprocessed:` is never enqueued on attach; call `enqueue!` on it when you want it. Stock Active Storage never preprocesses an async variant itself — the gem's `ProcessJob` is the only thing that does.
74
+
59
75
  In views, use the same Active Storage helpers:
60
76
 
61
77
  ```erb
@@ -205,7 +221,7 @@ variant.error # => "ffmpeg exited with status 1: ..."
205
221
  ### External transformer flow
206
222
 
207
223
  1. User uploads a file to an attachment that has async variants defined
208
- 2. After attachment, a background job is enqueued for each async variant
224
+ 2. After attachment, a background job is enqueued for each async variant whose `preprocessed:` holds for the record
209
225
  3. `VariantRecord` is created with state `pending`
210
226
  4. The job calls the transformer's `initiate` method with presigned source/destination URLs and a signed callback URL, then exits -- the worker is free
211
227
  5. The external service processes the file, uploads the result to the destination URL
@@ -4,9 +4,6 @@ module ActiveStorage
4
4
  module AsyncVariants
5
5
  class CallbacksController < ActionController::API
6
6
  def create
7
- variant_record_id = ActiveStorage.verifier.verify(params[:token], purpose: :async_variant_callback)
8
- variant_record = ActiveStorage::VariantRecord.find(variant_record_id)
9
-
10
7
  case params[:status]
11
8
  when "success"
12
9
  variant_record.update!(state: "processed")
@@ -29,33 +26,45 @@ module ActiveStorage
29
26
 
30
27
  private
31
28
 
32
- # External transformers (Crucible) write the file directly to the bucket
33
- # and report its byte_size/checksum on the success callback. Reconcile the
34
- # placeholder blobs created with byte_size: 0, checksum: "0" -- the variant
35
- # itself, and (for video previews) the extracted frame on the source blob.
29
+ def variant_record
30
+ @variant_record ||= begin
31
+ variant_record_id = ActiveStorage.verifier.verify(params[:token], purpose: :async_variant_callback)
32
+ ActiveStorage::VariantRecord.find(variant_record_id)
33
+ end
34
+ end
35
+
36
36
  def apply_reported_metadata(variant_record, params)
37
37
  reconcile(variant_record.image.blob, params[:byte_size], params[:checksum])
38
- reconcile(variant_record.blob, params[:preview_image_byte_size], params[:preview_image_checksum])
38
+ if frame?(variant_record.blob)
39
+ reconcile(variant_record.blob, params[:preview_image_byte_size], params[:preview_image_checksum])
40
+ end
39
41
  end
40
42
 
41
- # The placeholder sentinels (byte_size 0, checksum "0") gate each field, so
42
- # this is idempotent and never overwrites a real source blob's metadata.
43
+ # A re-render writes over a reused blob, so the reported values describe the object now stored.
43
44
  def reconcile(blob, byte_size, checksum)
44
45
  return unless blob
45
46
 
46
- attrs = {}
47
- if (bytes = positive_int(byte_size)) && blob.byte_size.zero?
48
- attrs[:byte_size] = bytes
47
+ if positive_int?(byte_size)
48
+ blob.byte_size = byte_size
49
49
  end
50
- if checksum.present? && checksum != "0" && blob.checksum == "0"
51
- attrs[:checksum] = checksum
50
+ if checksum.present?
51
+ blob.checksum = checksum
52
52
  end
53
- blob.update!(attrs) if attrs.any?
53
+ blob.save!
54
+
55
+ if blob.checksum != "0"
56
+ blob.mirror_later
57
+ end
58
+ end
59
+
60
+ # preview_image_* must never land on a variant's source blob.
61
+ def frame?(blob)
62
+ blob.checksum == "0" ||
63
+ ActiveStorage::Attachment.exists?(record_type: "ActiveStorage::Blob", name: "preview_image", blob_id: blob.id)
54
64
  end
55
65
 
56
- def positive_int(value)
57
- int = value.to_i
58
- int.positive? ? int : nil
66
+ def positive_int?(value)
67
+ value.to_i.positive?
59
68
  end
60
69
  end
61
70
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddStateColumnsToVariantRecords < ActiveRecord::Migration[7.2]
4
+ def change
5
+ add_column :active_storage_variant_records, :state, :string, default: "pending", if_not_exists: true
6
+ add_column :active_storage_variant_records, :error, :text, if_not_exists: true
7
+ add_column :active_storage_variant_records, :attempts, :integer, default: 0, if_not_exists: true
8
+ end
9
+ end
@@ -5,22 +5,39 @@ module ActiveStorage
5
5
  module AttachmentExtension
6
6
  private
7
7
 
8
+ # Stock would also preprocess an async variant: a TransformJob, or a PreviewImageJob that runs the local previewer.
8
9
  def transform_variants_later
9
- super
10
+ without_async_variants { super }
10
11
  enqueue_async_variant_jobs
11
12
  end
12
13
 
14
+ def named_variants
15
+ return super unless @without_async_variants
16
+ super.reject { |_name, named_variant| async_variant?(named_variant) }
17
+ end
18
+
19
+ def without_async_variants
20
+ @without_async_variants = true
21
+ yield
22
+ ensure
23
+ @without_async_variants = false
24
+ end
25
+
13
26
  def enqueue_async_variant_jobs
14
27
  return unless blob.bucket_backed?
15
28
 
16
29
  named_variants.each do |name, named_variant|
17
- next unless named_variant.transformations.key?(:async)
30
+ next unless async_variant?(named_variant) && named_variant.preprocessed?(record)
18
31
 
19
32
  ActiveStorage::AsyncVariants::ProcessJob.perform_later(
20
33
  record, self.name, name.to_s,
21
34
  )
22
35
  end
23
36
  end
37
+
38
+ def async_variant?(named_variant)
39
+ named_variant.transformations.key?(:transformer)
40
+ end
24
41
  end
25
42
  end
26
43
  end
@@ -7,8 +7,10 @@ module ActiveStorage
7
7
  # processing workers (Crucible, etc.) can reach via presigned URLs. The
8
8
  # Disk and Test services return false; the gem then defers to vanilla
9
9
  # ActiveStorage rather than trying to enqueue jobs or serve fallbacks.
10
+ # MirrorService doesn't delegate :bucket, so resolve through its primary.
10
11
  def bucket_backed?
11
- service.respond_to?(:bucket)
12
+ resolved = service.respond_to?(:primary) ? service.primary : service
13
+ resolved.respond_to?(:bucket)
12
14
  end
13
15
  end
14
16
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module AsyncVariants
5
+ # Finds the async named-variant declaration whose transformations match a
6
+ # variation, starting from a blob's attachments. Used to recover the
7
+ # :transformer option when the Registry is cold, and by
8
+ # Preview/VariantWithRecord enqueue paths to locate the attachment to
9
+ # dispatch ProcessJob against.
10
+ #
11
+ # Walks one level up through a preview_image attachment so a lookup from a
12
+ # video's extracted frame still finds the named variant declared on the
13
+ # parent record's source-video field.
14
+ #
15
+ # Candidates are compared structurally (order-insensitive, JSON-normalized)
16
+ # against the declared transformations, never by instantiating
17
+ # attachment.variant -- which raises InvariableError for previewable blobs.
18
+ module NamedVariantScan
19
+ module_function
20
+
21
+ def find(blob, variation, depth: 0)
22
+ blob.attachments.each do |attachment|
23
+ if attachment.name == "preview_image" && attachment.record_type == "ActiveStorage::Blob" && depth < 1
24
+ source = ActiveStorage::Blob.find_by(id: attachment.record_id)
25
+ result = source && find(source, variation, depth: depth + 1)
26
+ return result if result
27
+ next
28
+ end
29
+
30
+ attachment.send(:named_variants).each do |name, named_variant|
31
+ candidate = ActiveStorage::Variation.wrap(named_variant.transformations)
32
+ next unless candidate.async_options[:transformer]
33
+ return [attachment, name, candidate.async_options] if matches?(candidate, variation)
34
+ end
35
+ end
36
+ nil
37
+ end
38
+
39
+ # A format-less declaration matches any target format, because runtime
40
+ # resolution (Blob#variant's default_to) adds a blob-dependent format:
41
+ # the declaration never carries.
42
+ def matches?(candidate, variation)
43
+ declared = normalize(candidate.transformations)
44
+ target = normalize(variation.transformations)
45
+ declared_format = declared.delete("format")
46
+ target_format = target.delete("format")
47
+ declared == target && (declared_format.nil? || declared_format == target_format)
48
+ end
49
+
50
+ def normalize(transformations)
51
+ JSON.parse(transformations.to_json)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -11,13 +11,19 @@ module ActiveStorage
11
11
  end
12
12
 
13
13
  def enqueue!
14
- if result = find_named_async_variant
15
- attachment, variant_name, _ = result
16
-
17
- blob.variant_records.create!(
18
- variation_digest: variation.digest,
19
- state: "pending",
20
- )
14
+ return if async_record
15
+
16
+ if result = ActiveStorage::AsyncVariants::NamedVariantScan.find(blob, variation)
17
+ attachment, variant_name, options = result
18
+
19
+ transformer = options[:transformer]
20
+ if transformer&.new&.external?
21
+ ActiveStorage::AsyncVariants.ensure_preview_image_placeholder!(blob)
22
+ blob.preview_image.blob.variant_records.create!(
23
+ variation_digest: variant.variation.digest,
24
+ state: "pending",
25
+ )
26
+ end
21
27
  ActiveStorage::AsyncVariants::ProcessJob.perform_later(
22
28
  attachment.record, attachment.name, variant_name.to_s,
23
29
  )
@@ -26,13 +32,27 @@ module ActiveStorage
26
32
  # another caller (or a leftover record) wins; their job handles it
27
33
  end
28
34
 
35
+ def retry!
36
+ async_record&.destroy if async_state == "failed"
37
+ enqueue!
38
+ end
39
+
40
+ # Stock preview structure: the record hangs off the extracted frame (the
41
+ # blob's preview_image attachment), not the source blob. The digest is
42
+ # resolved through stock's Preview#variant so it matches the
43
+ # default_to-applied variation embedded in representation URLs.
44
+ def async_record
45
+ return nil unless blob.preview_image.attached?
46
+ blob.preview_image.blob.variant_records.find_by(variation_digest: variant.variation.digest)
47
+ end
48
+
29
49
  def processed?
30
50
  async_preview? ? preview_variant_processed? : super
31
51
  end
32
52
 
33
53
  def url(...)
34
54
  if async_preview?
35
- preview_variant_processed? ? find_preview_variant_record.image.url(...) : fallback_preview_url(...)
55
+ preview_variant_processed? ? async_record.image.url(...) : fallback_preview_url(...)
36
56
  else
37
57
  super
38
58
  end
@@ -41,7 +61,7 @@ module ActiveStorage
41
61
  def key
42
62
  if async_preview?
43
63
  raise ActiveStorage::Preview::UnprocessedError unless preview_variant_processed?
44
- find_preview_variant_record.image.blob.key
64
+ async_record.image.blob.key
45
65
  else
46
66
  super
47
67
  end
@@ -49,30 +69,44 @@ module ActiveStorage
49
69
 
50
70
  def async_state
51
71
  return nil unless async_preview?
52
- find_preview_variant_record&.state || "pending"
72
+ async_record&.state || "pending"
53
73
  end
54
74
 
55
75
  def progress
56
- find_preview_variant_record&.progress
76
+ async_record&.progress
57
77
  end
58
78
 
59
79
  def progress_known?
60
- !find_preview_variant_record&.progress.nil?
80
+ !async_record&.progress.nil?
81
+ end
82
+
83
+ # Mirror of VariantWithRecordExtension so a Preview is a drop-in for the
84
+ # UI layer's progress protocol: percent-per-second over elapsed processing
85
+ # time, driving the progress bar's optimistic creep between polls.
86
+ def progress_rate
87
+ record = async_record
88
+ return nil unless record&.created_at && record.last_heartbeat_at && record.progress&.positive?
89
+ elapsed = record.last_heartbeat_at - record.created_at
90
+ elapsed.positive? ? (record.progress / elapsed).round(4) : nil
61
91
  end
62
92
 
63
93
  def last_heartbeat_at
64
- find_preview_variant_record&.last_heartbeat_at
94
+ async_record&.last_heartbeat_at
95
+ end
96
+
97
+ def error
98
+ async_record&.error
65
99
  end
66
100
 
67
101
  private
68
102
 
69
103
  def async_preview?
70
- resolved_async_options[:async].present?
104
+ resolved_async_options[:transformer].present?
71
105
  end
72
106
 
73
107
  # Variations rebuilt from the redirect URL only carry transformations --
74
- # :async / :transformer are stripped at Variation#initialize and not
75
- # embedded in the URL key. Recover them via the digest-keyed registry that
108
+ # :transformer is stripped at Variation#initialize and not
109
+ # embedded in the URL key. Recover it via the digest-keyed registry that
76
110
  # VariationExtension warms on every view-side variant call, or fall back to
77
111
  # scanning attached named variants when the registry is cold (autoloader
78
112
  # hasn't touched the consumer model yet).
@@ -80,31 +114,12 @@ module ActiveStorage
80
114
  @resolved_async_options ||=
81
115
  variation.async_options.presence ||
82
116
  ActiveStorage::AsyncVariants::Registry[variation.digest] ||
83
- find_named_async_variant&.dig(2) ||
117
+ ActiveStorage::AsyncVariants::NamedVariantScan.find(blob, variation)&.dig(2) ||
84
118
  {}
85
119
  end
86
120
 
87
- def find_named_async_variant
88
- target = variation.transformations.to_json
89
- blob.attachments.each do |attachment|
90
- attachment.send(:named_variants).each do |name, _|
91
- candidate = attachment.variant(name.to_sym)
92
- next unless candidate.variation.transformations.to_json == target
93
- return [attachment, name, candidate.variation.async_options] if candidate.variation.async_options[:async]
94
- end
95
- end
96
- nil
97
- end
98
-
99
121
  def preview_variant_processed?
100
- find_preview_variant_record&.state == "processed"
101
- end
102
-
103
- # ProcessJob stores variant_records on the source blob (i.e. @variant.blob,
104
- # which for a named variant declared on a previewable attachment is the
105
- # original blob -- not preview_image.blob). Read from the same place.
106
- def find_preview_variant_record
107
- blob.variant_records.find_by(variation_digest: variation.digest)
122
+ async_record&.state == "processed"
108
123
  end
109
124
 
110
125
  # Serve the original until the preview variant is processed.
@@ -9,24 +9,12 @@ module ActiveStorage
9
9
 
10
10
  def perform(record, attachment_name, variant_name)
11
11
  attachment = record.public_send(attachment_name)
12
- @variant = attachment.variant(variant_name.to_sym)
13
- variation = @variant.variation
14
- @async_options = variation.async_options
15
- transformer_class = @async_options[:transformer]
16
- blob = @variant.blob
17
-
18
- @variant_record = blob.variant_records.create_or_find_by!(variation_digest: variation.digest)
19
- @variant_record.update!(state: "processing")
20
-
21
- if transformer_class
22
- transformer = transformer_class.new
23
- if transformer.inline?
24
- process_inline(blob, @variant_record, transformer, variation)
25
- else
26
- process_external(blob, @variant_record, transformer, variation)
27
- end
12
+ representation = attachment.representation(variant_name.to_sym)
13
+
14
+ if representation.is_a?(ActiveStorage::Preview)
15
+ perform_preview(representation)
28
16
  else
29
- process_default(blob, @variant_record, variation)
17
+ perform_variant(representation)
30
18
  end
31
19
  rescue => e
32
20
  @variant_record&.update!(
@@ -39,6 +27,54 @@ module ActiveStorage
39
27
 
40
28
  private
41
29
 
30
+ def perform_variant(variant)
31
+ blob = variant.blob
32
+ @variant_record = blob.variant_records.create_or_find_by!(variation_digest: variant.variation.digest)
33
+ return if @variant_record.processed? || @variant_record.in_flight?
34
+ @variant_record.update!(state: "processing", last_heartbeat_at: Time.current)
35
+
36
+ dispatch(variant.variation, transform_blob: blob, source_blob: blob)
37
+ end
38
+
39
+ # Stock preview structure: the frame lives as a preview_image attachment
40
+ # on the source blob, and the variant record hangs off the frame blob.
41
+ # Standard/inline transformers need the real frame up front (the stock
42
+ # previewer extracts it); external transformers get a placeholder the
43
+ # service writes into, plus the source blob to extract from.
44
+ def perform_preview(preview)
45
+ blob = preview.blob
46
+
47
+ if transformer_for(preview.variation).external?
48
+ ActiveStorage::AsyncVariants.ensure_preview_image_placeholder!(blob)
49
+ else
50
+ preview.send(:process) unless blob.preview_image.attached?
51
+ end
52
+
53
+ frame_blob = blob.preview_image.blob
54
+ variation = preview.send(:variant).variation
55
+ @variant_record = frame_blob.variant_records.create_or_find_by!(variation_digest: variation.digest)
56
+ return if @variant_record.processed? || @variant_record.in_flight?
57
+ @variant_record.update!(state: "processing", last_heartbeat_at: Time.current)
58
+
59
+ dispatch(variation, transform_blob: frame_blob, source_blob: blob)
60
+ end
61
+
62
+ def dispatch(variation, transform_blob:, source_blob:)
63
+ transformer = transformer_for(variation)
64
+
65
+ if transformer.standard?
66
+ process_default(transform_blob, @variant_record, variation)
67
+ elsif transformer.inline?
68
+ process_inline(transform_blob, @variant_record, transformer, variation)
69
+ else
70
+ process_external(source_blob, @variant_record, transformer, variation)
71
+ end
72
+ end
73
+
74
+ def transformer_for(variation)
75
+ (variation.async_options[:transformer] || ActiveStorage::AsyncVariants::Standard).new
76
+ end
77
+
42
78
  def process_inline(blob, variant_record, transformer, variation)
43
79
  options = variation.transformations
44
80
 
@@ -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, async: true
10
+ # transformer: Crucible
11
11
  # end
12
12
  #
13
13
  # Without this, the Registry only warms when view-side code calls
@@ -28,7 +28,7 @@ module ActiveStorage
28
28
  # matches the URL-side digest for variants that declare an explicit
29
29
  # `format:`. Variants without an explicit `format:` still warm lazily
30
30
  # on first view-side call against a blob of that content type.
31
- ActiveStorage::Variation.wrap(transformations).default_to(format: :png)
31
+ ActiveStorage::Variation.wrap(transformations.except(:preprocessed)).default_to(format: :png)
32
32
  end
33
33
  end
34
34
  end
@@ -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 :async in its async_options -- which happens on
10
+ # constructed with a :transformer 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.
@@ -2,6 +2,15 @@
2
2
 
3
3
  module ActiveStorage
4
4
  module AsyncVariants
5
+ # Naming a transformer on a variant is what opts it into async processing.
6
+ # Three kinds, distinguished by what they override:
7
+ #
8
+ # - standard (overrides neither) -- runs the stock Active Storage variant
9
+ # pipeline in the background. Use the built-in Standard below.
10
+ # - inline (overrides #process) -- does the work in the worker, returns
11
+ # { io:, content_type:, filename: }.
12
+ # - external (overrides #initiate) -- hands off to a remote service that
13
+ # POSTs back to the callback URL when done.
5
14
  class Transformer
6
15
  def process(file, **options)
7
16
  raise NotImplementedError, "#{self.class}#process must return { io:, content_type:, filename: }"
@@ -12,8 +21,30 @@ module ActiveStorage
12
21
  end
13
22
 
14
23
  def inline?
15
- self.class.instance_method(:process).owner != ActiveStorage::AsyncVariants::Transformer
24
+ overrides?(:process)
16
25
  end
26
+
27
+ def external?
28
+ overrides?(:initiate)
29
+ end
30
+
31
+ def standard?
32
+ !inline? && !external?
33
+ end
34
+
35
+ private
36
+
37
+ def overrides?(method_name)
38
+ self.class.instance_method(method_name).owner != ActiveStorage::AsyncVariants::Transformer
39
+ end
40
+ end
41
+
42
+ # Async processing via the stock Active Storage variant pipeline -- the
43
+ # replacement for the old bare `async: true` (no transformer) declaration.
44
+ #
45
+ # attachable.variant :thumb, resize_to_limit: [100, 100],
46
+ # transformer: ActiveStorage::AsyncVariants::Standard
47
+ class Standard < Transformer
17
48
  end
18
49
  end
19
50
  end
@@ -7,6 +7,16 @@ module ActiveStorage
7
7
 
8
8
  included do
9
9
  after_update_commit :touch_attached_records, if: :reached_terminal_state?
10
+ after_destroy_commit :purge_late_outputs
11
+ end
12
+
13
+ def processed?
14
+ state == "processed"
15
+ end
16
+
17
+ # A run that stopped heartbeating is presumed dead, so it may be retried.
18
+ def in_flight?
19
+ state == "processing" && last_heartbeat_at.present? && last_heartbeat_at > ActiveStorage::AsyncVariants.heartbeat_stale_after.ago
10
20
  end
11
21
 
12
22
  private
@@ -19,6 +29,11 @@ module ActiveStorage
19
29
  saved_change_to_state? && %w[processed failed].include?(state)
20
30
  end
21
31
 
32
+ # A render that held the row lock can commit an output after this destroy read the record's attachments.
33
+ def purge_late_outputs
34
+ ActiveStorage::Attachment.where(record: self).find_each(&:purge_later)
35
+ end
36
+
22
37
  def touch_attached_records
23
38
  blob.attachments.includes(:record).each do |attachment|
24
39
  attachment.record&.touch
@@ -26,6 +26,15 @@ module ActiveStorage
26
26
  # another caller (or a leftover record) wins; their job handles it
27
27
  end
28
28
 
29
+ def retry!
30
+ async_record&.destroy if failed?
31
+ enqueue!
32
+ end
33
+
34
+ def async_record
35
+ blob.variant_records.find_by(variation_digest: variation.digest)
36
+ end
37
+
29
38
  def url(...)
30
39
  if blob.bucket_backed? && async_variant? && !processed?
31
40
  blob.url(...)
@@ -87,7 +96,7 @@ module ActiveStorage
87
96
  private
88
97
 
89
98
  def async_variant?
90
- resolved_async_options[:async].present?
99
+ resolved_async_options[:transformer].present?
91
100
  end
92
101
 
93
102
  def resolved_async_options
@@ -103,36 +112,8 @@ module ActiveStorage
103
112
  # a fallback when the Registry is cold (e.g. in dev, when a
104
113
  # RepresentationsRedirectController request hits a worker that hasn't
105
114
  # autoloaded the consumer model yet).
106
- #
107
- # Walks one level through preview_image attachments so a request for
108
- # a Variant of a video's extracted preview frame can still find the
109
- # named variant declared on the parent record's source-video field.
110
115
  def find_named_async_variant
111
- target = variation.transformations.to_json
112
- scan_for_named_variant(blob, target)
113
- end
114
-
115
- def scan_for_named_variant(blob_to_scan, target, depth: 0)
116
- blob_to_scan.attachments.each do |attachment|
117
- if attachment.name == "preview_image" && attachment.record_type == "ActiveStorage::Blob" && depth < 1
118
- source = ActiveStorage::Blob.find_by(id: attachment.record_id)
119
- result = source && scan_for_named_variant(source, target, depth: depth + 1)
120
- return result if result
121
- next
122
- end
123
-
124
- attachment.send(:named_variants).each do |name, _|
125
- candidate = attachment.variant(name.to_sym)
126
- if candidate.variation.transformations.to_json == target
127
- return [attachment, name, candidate.variation.async_options] if candidate.variation.async_options[:async]
128
- end
129
- end
130
- end
131
- nil
132
- end
133
-
134
- def async_record
135
- blob.variant_records.find_by(variation_digest: variation.digest)
116
+ ActiveStorage::AsyncVariants::NamedVariantScan.find(blob, variation)
136
117
  end
137
118
  end
138
119
  end
@@ -3,17 +3,20 @@
3
3
  module ActiveStorage
4
4
  module AsyncVariants
5
5
  module VariationExtension
6
- ASYNC_KEYS = %i[async transformer].freeze
7
-
6
+ # Stripped before stock sees it (it would treat it as an unknown image
7
+ # operation). Naming a `:transformer` is the async opt-in.
8
8
  def initialize(transformations)
9
9
  if transformations.is_a?(Hash)
10
- @async_options = transformations.slice(*ASYNC_KEYS)
11
- super(transformations.except(*ASYNC_KEYS))
10
+ if transformations.key?(:async)
11
+ raise ArgumentError, "async: is no longer supported; name a transformer: to opt a variant into async processing"
12
+ end
13
+ @async_options = transformations.slice(:transformer)
14
+ super(transformations.except(:transformer))
12
15
  else
13
16
  @async_options = {}
14
17
  super
15
18
  end
16
- ActiveStorage::AsyncVariants::Registry.register(digest, @async_options) if @async_options[:async]
19
+ ActiveStorage::AsyncVariants::Registry.register(digest, @async_options) if @async_options[:transformer]
17
20
  end
18
21
 
19
22
  def async_options
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveStorage
4
4
  module AsyncVariants
5
- VERSION = "0.9.0"
5
+ VERSION = "0.10.0"
6
6
  end
7
7
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "async_variants/version"
4
4
  require_relative "async_variants/transformer"
5
5
  require_relative "async_variants/registry"
6
+ require_relative "async_variants/named_variant_scan"
6
7
  require_relative "async_variants/blob_extension"
7
8
  require_relative "async_variants/variation_extension"
8
9
  require_relative "async_variants/variant_with_record_extension"
@@ -75,6 +76,34 @@ module ActiveStorage
75
76
  )
76
77
  end
77
78
 
79
+ # Attaches a frame placeholder (byte_size 0, checksum "0") as the blob's
80
+ # preview_image, mirroring stock's once-per-blob preview extraction. The
81
+ # external transformer writes the real frame to it; the success callback
82
+ # reconciles the sentinel metadata.
83
+ #
84
+ # Serialized on the blob row: every image variant of one video runs this
85
+ # concurrently, and a second frame would be invisible to has_one
86
+ # :preview_image, stranding the records that hang off it.
87
+ def self.ensure_preview_image_placeholder!(blob)
88
+ return blob.preview_image.blob if blob.preview_image.attached?
89
+
90
+ blob.with_lock do
91
+ # with_lock reloaded the blob, so this sees a frame a racing caller just committed.
92
+ next blob.preview_image.blob if blob.preview_image.attached?
93
+
94
+ frame = ActiveStorage::Blob.create_before_direct_upload!(
95
+ filename: "#{blob.filename.base}.jpg",
96
+ content_type: "image/jpeg",
97
+ metadata: { analyzed: true },
98
+ service_name: blob.service_name,
99
+ byte_size: 0,
100
+ checksum: "0",
101
+ )
102
+ blob.preview_image.attach(frame)
103
+ frame
104
+ end
105
+ end
106
+
78
107
  def self.callback_token_for(variant_record)
79
108
  ActiveStorage.verifier.generate(variant_record.id, purpose: :async_variant_callback)
80
109
  end
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.9.0
4
+ version: 0.10.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 00:00:00.000000000 Z
10
+ date: 2026-11-14 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activestorage
@@ -40,6 +40,7 @@ files:
40
40
  - Rakefile
41
41
  - app/controllers/active_storage/async_variants/callbacks_controller.rb
42
42
  - config/routes.rb
43
+ - db/migrate/20260304000001_add_state_columns_to_variant_records.rb
43
44
  - db/migrate/20260607000001_add_heartbeat_columns_to_variant_records.rb
44
45
  - db/migrate/20260608000001_add_created_at_to_variant_records.rb
45
46
  - gemfiles/rails_7.2.gemfile
@@ -49,6 +50,7 @@ files:
49
50
  - lib/active_storage/async_variants/attachment_extension.rb
50
51
  - lib/active_storage/async_variants/blob_extension.rb
51
52
  - lib/active_storage/async_variants/heartbeat_watchdog_job.rb
53
+ - lib/active_storage/async_variants/named_variant_scan.rb
52
54
  - lib/active_storage/async_variants/preview_extension.rb
53
55
  - lib/active_storage/async_variants/process_job.rb
54
56
  - lib/active_storage/async_variants/reflection_extension.rb