active_storage-async_variants 0.1.0 → 0.3.1

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.
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module AsyncVariants
5
+ module PreviewExtension
6
+ # Enqueue (or no-op if already done) the same ProcessJob the
7
+ # VariantWithRecord path uses, so Preview-side and VariantWithRecord-side
8
+ # processing converge on a single record-and-job machinery rather than
9
+ # the gem's earlier two-path design (one writing to preview_image's
10
+ # variant_records, one to the original blob's).
11
+ def processed
12
+ if async_preview?
13
+ enqueue_async_preview unless preview_variant_processed?
14
+ self
15
+ else
16
+ super
17
+ end
18
+ end
19
+
20
+ def processed?
21
+ async_preview? ? preview_variant_processed? : super
22
+ end
23
+
24
+ def url(...)
25
+ if async_preview?
26
+ preview_variant_processed? ? find_preview_variant_record.image.url(...) : fallback_preview_url(...)
27
+ else
28
+ super
29
+ end
30
+ end
31
+
32
+ def key
33
+ if async_preview?
34
+ raise ActiveStorage::Preview::UnprocessedError unless preview_variant_processed?
35
+ find_preview_variant_record.image.blob.key
36
+ else
37
+ super
38
+ end
39
+ end
40
+
41
+ def async_state
42
+ return nil unless async_preview?
43
+ find_preview_variant_record&.state || "pending"
44
+ end
45
+
46
+ private
47
+
48
+ def async_preview?
49
+ resolved_async_options[:transformer].present?
50
+ end
51
+
52
+ # Variations rebuilt from the redirect URL only carry transformations --
53
+ # :transformer / :processing / :failed are stripped at Variation#initialize
54
+ # and not embedded in the URL key. Recover them via the digest-keyed
55
+ # registry that VariationExtension warms on every view-side variant call.
56
+ def resolved_async_options
57
+ @resolved_async_options ||=
58
+ variation.async_options.presence ||
59
+ ActiveStorage::AsyncVariants::Registry[variation.digest] ||
60
+ {}
61
+ end
62
+
63
+ def preview_variant_processed?
64
+ find_preview_variant_record&.state == "processed"
65
+ end
66
+
67
+ # ProcessJob stores variant_records on the source blob (i.e. @variant.blob,
68
+ # which for a named variant declared on a previewable attachment is the
69
+ # original blob -- not preview_image.blob). Read from the same place.
70
+ def find_preview_variant_record
71
+ blob.variant_records.find_by(variation_digest: variation.digest)
72
+ end
73
+
74
+ # Delegate to the named-variant VariantWithRecord so we go through the
75
+ # exact same enqueue_processing + ProcessJob machinery as direct
76
+ # variant calls. Skips silently if no matching named variant exists,
77
+ # which can happen for raw transformations that don't correspond to
78
+ # any declared async variant. Also skipped on non-bucket services,
79
+ # where the gem defers to vanilla ActiveStorage and dispatching here
80
+ # would synchronously transform via vips (broken for video blobs).
81
+ def enqueue_async_preview
82
+ return unless blob.bucket_backed?
83
+ target = variation.transformations.to_json
84
+ blob.attachments.each do |attachment|
85
+ attachment.send(:named_variants).each do |name, _|
86
+ candidate = attachment.variant(name.to_sym)
87
+ next unless candidate.variation.transformations.to_json == target
88
+ next unless candidate.variation.async_options[:processing].present?
89
+ candidate.processed
90
+ return
91
+ end
92
+ end
93
+ end
94
+
95
+ def fallback_preview_url(...)
96
+ case active_fallback
97
+ when :original then blob.url(...)
98
+ when :blank then nil
99
+ when Proc then active_fallback.call(blob)
100
+ when String then active_fallback
101
+ end
102
+ end
103
+
104
+ def active_fallback
105
+ if failed?
106
+ resolved_async_options.fetch(:failed) { resolved_async_options[:processing] }
107
+ else
108
+ resolved_async_options[:processing]
109
+ end
110
+ end
111
+
112
+ def failed?
113
+ find_preview_variant_record&.state == "failed"
114
+ end
115
+ end
116
+ end
117
+ end
@@ -3,6 +3,10 @@
3
3
  module ActiveStorage
4
4
  module AsyncVariants
5
5
  class ProcessJob < ActiveJob::Base
6
+ retry_on StandardError, wait: :polynomially_longer, attempts: 3 do |job, error|
7
+ job.logger.error "AsyncVariants: permanently failed: #{error.message}"
8
+ end
9
+
6
10
  def perform(record, attachment_name, variant_name)
7
11
  attachment = record.public_send(attachment_name)
8
12
  @variant = attachment.variant(variant_name.to_sym)
@@ -27,15 +31,10 @@ module ActiveStorage
27
31
  rescue => e
28
32
  @variant_record&.update!(
29
33
  state: "failed",
30
- error: e.message,
34
+ error: e.message.to_s.truncate(16_000),
31
35
  attempts: (@variant_record&.attempts || 0) + 1,
32
36
  )
33
- max_retries = @async_options&.fetch(:max_retries, 3) || 3
34
- if @variant_record.nil? || @variant_record.attempts < max_retries
35
- raise
36
- else
37
- logger.error "AsyncVariants: permanently failed after #{@variant_record.attempts} attempts: #{e.message}"
38
- end
37
+ raise
39
38
  end
40
39
 
41
40
  private
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module AsyncVariants
5
+ # Eagerly warm the digest-keyed Registry when a named variant is declared
6
+ # via has_X_attached's block:
7
+ #
8
+ # has_one_attached :avatar do |attachable|
9
+ # attachable.variant :thumb, resize_to_limit: [150, 150], format: "webp",
10
+ # transformer: Crucible, processing: "/spinner.svg"
11
+ # end
12
+ #
13
+ # Without this, the Registry only warms when view-side code calls
14
+ # `attachment.variant(:thumb)` -- so a Puma worker that has never rendered
15
+ # a view for the variant misses on URL-reconstructed lookups.
16
+ #
17
+ # Caveat: registration uses the declared transformations. Rails applies a
18
+ # blob-dependent default `format:` at runtime via default_to. Variants that
19
+ # specify `format:` explicitly register the same digest the URL carries
20
+ # (cold-resolvable). Variants that rely on the format default still warm
21
+ # lazily on first view-side call against a blob of that content type.
22
+ module ReflectionExtension
23
+ def variant(name, transformations)
24
+ super
25
+ return unless transformations.is_a?(Hash)
26
+ # Mirror Blob#variant at runtime: wrap and default_to. The default_to
27
+ # call reorders hash keys (default key first) so the registered digest
28
+ # matches the URL-side digest for variants that declare an explicit
29
+ # `format:`. Variants without an explicit `format:` still warm lazily
30
+ # on first view-side call against a blob of that content type.
31
+ ActiveStorage::Variation.wrap(transformations).default_to(format: :png)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module ActiveStorage
6
+ module AsyncVariants
7
+ # Process-wide lookup of async_options keyed by Variation#digest.
8
+ #
9
+ # Populated lazily by VariationExtension#initialize whenever a Variation is
10
+ # constructed with :transformer in its async_options -- which happens on
11
+ # every view-side `attachment.variant(:name)` call. The redirect controller
12
+ # then resolves async_options by digest without scanning blob.attachments
13
+ # for a transformations-match.
14
+ #
15
+ # Cold-worker behavior: if a worker receives a redirect-URL request before
16
+ # any view-side call has registered the variant's digest, the lookup misses
17
+ # and resolution falls through to standard Rails (no leak, no spinner).
18
+ # Self-warms on subsequent view requests.
19
+ class Registry
20
+ MONITOR = Monitor.new
21
+ STORE = {}
22
+
23
+ class << self
24
+ def register(digest, async_options)
25
+ MONITOR.synchronize { STORE[digest] = async_options }
26
+ end
27
+
28
+ def [](digest)
29
+ MONITOR.synchronize { STORE[digest] }
30
+ end
31
+
32
+ def clear
33
+ MONITOR.synchronize { STORE.clear }
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module AsyncVariants
5
+ module RepresentationsRedirectControllerExtension
6
+ ASYNC_HEADER = "X-Async-Variant-State"
7
+ INTERCEPT_STATES = %w[pending processing failed].freeze
8
+
9
+ def show
10
+ state = @representation.async_state
11
+ if INTERCEPT_STATES.include?(state)
12
+ fallback = @representation.url(disposition: params[:disposition])
13
+ if fallback.is_a?(String) && fallback.start_with?("/")
14
+ path = Rails.public_path.join(fallback.delete_prefix("/"))
15
+ if File.exist?(path)
16
+ response.set_header(ASYNC_HEADER, state)
17
+ response.set_header("Cache-Control", "no-store, private")
18
+ return send_file path, disposition: "inline"
19
+ end
20
+ end
21
+ end
22
+ super
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module AsyncVariants
5
+ module VariantRecordExtension
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ after_update_commit :touch_attached_records, if: :reached_terminal_state?
10
+ end
11
+
12
+ private
13
+
14
+ # processed and failed are terminal states: cache fragments built when
15
+ # state was pending/processing need to be invalidated so the next
16
+ # render sees the new state (and serves the failed: fallback URL,
17
+ # swaps src to the direct CDN URL, etc.).
18
+ def reached_terminal_state?
19
+ saved_change_to_state? && %w[processed failed].include?(state)
20
+ end
21
+
22
+ def touch_attached_records
23
+ blob.attachments.includes(:record).each do |attachment|
24
+ attachment.record&.touch
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -3,16 +3,36 @@
3
3
  module ActiveStorage
4
4
  module AsyncVariants
5
5
  module VariantWithRecordExtension
6
- def url(...)
7
- if processed?
6
+ def processed
7
+ if blob.bucket_backed?
8
+ enqueue_processing unless processed? || processing?
9
+ self
10
+ else
8
11
  super
12
+ end
13
+ end
14
+
15
+ def url(...)
16
+ if blob.bucket_backed? && !processed?
17
+ fallback = active_fallback
18
+ case fallback
19
+ when :original then blob.url(...)
20
+ when :blank then nil
21
+ when Proc then fallback.call(blob)
22
+ when String then fallback
23
+ else super
24
+ end
9
25
  else
10
- fallback_url(...)
26
+ super
11
27
  end
12
28
  end
13
29
 
14
- def ready?
15
- async_record&.state == "processed"
30
+ def processed?
31
+ if blob.bucket_backed?
32
+ async_record&.state == "processed"
33
+ else
34
+ super
35
+ end
16
36
  end
17
37
 
18
38
  def processing?
@@ -31,29 +51,68 @@ module ActiveStorage
31
51
  async_record&.error
32
52
  end
33
53
 
54
+ def async_state
55
+ return nil unless blob.bucket_backed?
56
+ async_record&.state || "pending"
57
+ end
58
+
34
59
  private
35
60
 
36
- def processed?
37
- async? ? ready? : super
61
+ def resolved_async_options
62
+ @resolved_async_options ||=
63
+ variation.async_options.presence ||
64
+ ActiveStorage::AsyncVariants::Registry[variation.digest] ||
65
+ {}
38
66
  end
39
67
 
40
- def async?
41
- variation.async_options[:fallback].present?
68
+ def active_fallback
69
+ if failed?
70
+ resolved_async_options.fetch(:failed) { resolved_async_options[:processing] }
71
+ else
72
+ resolved_async_options[:processing]
73
+ end
42
74
  end
43
75
 
44
- def async_record
45
- blob.variant_records.find_by(variation_digest: variation.digest)
76
+ def enqueue_processing
77
+ result = find_named_async_variant
78
+ return unless result
79
+ attachment, variant_name, _ = result
80
+
81
+ return if async_record
82
+
83
+ blob.variant_records.create!(
84
+ variation_digest: variation.digest,
85
+ state: "pending",
86
+ )
87
+
88
+ ActiveStorage::AsyncVariants::ProcessJob.perform_later(
89
+ attachment.record, attachment.name, variant_name.to_s,
90
+ )
91
+ rescue ActiveRecord::RecordNotUnique
92
+ # another caller won the race; their job will handle processing
46
93
  end
47
94
 
48
- def fallback_url(...)
49
- case variation.async_options[:fallback]
50
- when :original
51
- blob.url(...)
52
- when :blank
53
- nil
54
- when Proc
55
- variation.async_options[:fallback].call(blob)
95
+ # Cold-path scan: only used by enqueue_processing, which needs the
96
+ # (attachment.record, attachment.name, variant_name) tuple to dispatch
97
+ # ProcessJob -- more than the digest registry stores. Hot-path URL
98
+ # resolution goes through Registry, not this method.
99
+ def find_named_async_variant
100
+ target = variation.transformations.to_json
101
+
102
+ blob.attachments.each do |attachment|
103
+ attachment.send(:named_variants).each do |name, _|
104
+ candidate = attachment.variant(name.to_sym)
105
+ if candidate.variation.transformations.to_json == target
106
+ return [attachment, name, candidate.variation.async_options] if candidate.variation.async_options[:processing].present?
107
+ end
108
+ end
56
109
  end
110
+
111
+ nil
112
+ end
113
+
114
+ def async_record
115
+ blob.variant_records.find_by(variation_digest: variation.digest)
57
116
  end
58
117
  end
59
118
  end
@@ -3,7 +3,7 @@
3
3
  module ActiveStorage
4
4
  module AsyncVariants
5
5
  module VariationExtension
6
- ASYNC_KEYS = %i[fallback transformer max_retries].freeze
6
+ ASYNC_KEYS = %i[processing failed transformer].freeze
7
7
 
8
8
  def initialize(transformations)
9
9
  if transformations.is_a?(Hash)
@@ -13,6 +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[:processing].present?
16
17
  end
17
18
 
18
19
  def async_options
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveStorage
4
4
  module AsyncVariants
5
- VERSION = "0.1.0"
5
+ VERSION = "0.3.1"
6
6
  end
7
7
  end
@@ -2,36 +2,97 @@
2
2
 
3
3
  require_relative "async_variants/version"
4
4
  require_relative "async_variants/transformer"
5
+ require_relative "async_variants/registry"
6
+ require_relative "async_variants/blob_extension"
5
7
  require_relative "async_variants/variation_extension"
6
8
  require_relative "async_variants/variant_with_record_extension"
9
+ require_relative "async_variants/variant_record_extension"
10
+ require_relative "async_variants/preview_extension"
7
11
  require_relative "async_variants/attachment_extension"
12
+ require_relative "async_variants/reflection_extension"
8
13
  require_relative "async_variants/process_job"
14
+ require_relative "async_variants/representations_redirect_controller_extension"
15
+ require_relative "async_variants/asset_tag_helper_extension"
9
16
 
10
17
  module ActiveStorage
11
18
  module AsyncVariants
19
+ mattr_accessor :cdn_host
20
+
12
21
  class Engine < ::Rails::Engine
22
+ # :nocov:
23
+ initializer "active_storage_async_variants.assets" do |app|
24
+ if app.config.respond_to?(:assets)
25
+ app.config.assets.precompile += %w[active_storage_async_variants.js]
26
+ end
27
+ end
28
+ # :nocov:
29
+
30
+ # Prepend the core model/reflection extensions before eager_load runs
31
+ # so that models' has_X_attached blocks (and the Variation.wrap calls
32
+ # they trigger via reflection.variant) go through our hooks. The
33
+ # :before_eager_load load_hook fires from the eager_load! initializer
34
+ # in finisher_hook, after all autoload paths have been set up but
35
+ # before any model class is loaded.
36
+ # :nocov:
37
+ ActiveSupport.on_load(:before_eager_load) do
38
+ ActiveStorage::AsyncVariants.prepend_model_extensions!
39
+ end
40
+ # :nocov:
41
+
13
42
  config.after_initialize do
14
- ActiveStorage::Variation.prepend(
15
- ActiveStorage::AsyncVariants::VariationExtension
16
- )
17
- ActiveStorage::VariantWithRecord.prepend(
18
- ActiveStorage::AsyncVariants::VariantWithRecordExtension
43
+ # Idempotent — covers eager_load=false (dev/test) where the
44
+ # :before_eager_load hook never fires. Models autoload lazily on
45
+ # demand, and we just need the extensions in place by the time
46
+ # the first one loads.
47
+ ActiveStorage::AsyncVariants.prepend_model_extensions!
48
+
49
+ ActiveStorage::Representations::RedirectController.prepend(
50
+ ActiveStorage::AsyncVariants::RepresentationsRedirectControllerExtension
19
51
  )
20
- ActiveStorage::Attachment.prepend(
21
- ActiveStorage::AsyncVariants::AttachmentExtension
52
+ ActionView::Helpers::AssetTagHelper.prepend(
53
+ ActiveStorage::AsyncVariants::AssetTagHelperExtension
22
54
  )
23
55
  end
24
56
  end
25
57
 
58
+ def self.prepend_model_extensions!
59
+ return if @model_extensions_prepended
60
+ @model_extensions_prepended = true
61
+
62
+ require "active_storage/reflection"
63
+ ActiveStorage::Reflection::HasAttachedReflection.prepend(
64
+ ActiveStorage::AsyncVariants::ReflectionExtension
65
+ )
66
+ ActiveStorage::Blob.prepend(
67
+ ActiveStorage::AsyncVariants::BlobExtension
68
+ )
69
+ ActiveStorage::Variation.prepend(
70
+ ActiveStorage::AsyncVariants::VariationExtension
71
+ )
72
+ ActiveStorage::VariantWithRecord.prepend(
73
+ ActiveStorage::AsyncVariants::VariantWithRecordExtension
74
+ )
75
+ ActiveStorage::VariantRecord.include(
76
+ ActiveStorage::AsyncVariants::VariantRecordExtension
77
+ )
78
+ ActiveStorage::Attachment.prepend(
79
+ ActiveStorage::AsyncVariants::AttachmentExtension
80
+ )
81
+ ActiveStorage::Preview.prepend(
82
+ ActiveStorage::AsyncVariants::PreviewExtension
83
+ )
84
+ end
85
+
26
86
  def self.callback_token_for(variant_record)
27
87
  ActiveStorage.verifier.generate(variant_record.id, purpose: :async_variant_callback)
28
88
  end
29
89
 
30
90
  def self.callback_url_for(variant_record)
91
+ url_options = ActiveStorage::Current.url_options || Rails.application.default_url_options
31
92
  token = callback_token_for(variant_record)
32
93
  Rails.application.routes.url_helpers.active_storage_async_variant_callback_url(
33
94
  token: token,
34
- **ActiveStorage::Current.url_options,
95
+ **url_options,
35
96
  )
36
97
  end
37
98
  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.1.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Micah Geisel
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-03-04 00:00:00.000000000 Z
10
+ date: 2026-05-29 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activestorage
@@ -38,18 +38,23 @@ files:
38
38
  - LICENSE.txt
39
39
  - README.md
40
40
  - Rakefile
41
+ - app/assets/javascripts/active_storage_async_variants.js
41
42
  - app/controllers/active_storage/async_variants/callbacks_controller.rb
42
43
  - config/routes.rb
43
44
  - gemfiles/rails_7.2.gemfile
44
- - gemfiles/rails_7.2.gemfile.lock
45
45
  - gemfiles/rails_8.0.gemfile
46
- - gemfiles/rails_8.0.gemfile.lock
47
46
  - gemfiles/rails_8.1.gemfile
48
- - gemfiles/rails_8.1.gemfile.lock
49
47
  - lib/active_storage/async_variants.rb
48
+ - lib/active_storage/async_variants/asset_tag_helper_extension.rb
50
49
  - lib/active_storage/async_variants/attachment_extension.rb
50
+ - lib/active_storage/async_variants/blob_extension.rb
51
+ - lib/active_storage/async_variants/preview_extension.rb
51
52
  - lib/active_storage/async_variants/process_job.rb
53
+ - lib/active_storage/async_variants/reflection_extension.rb
54
+ - lib/active_storage/async_variants/registry.rb
55
+ - lib/active_storage/async_variants/representations_redirect_controller_extension.rb
52
56
  - lib/active_storage/async_variants/transformer.rb
57
+ - lib/active_storage/async_variants/variant_record_extension.rb
53
58
  - lib/active_storage/async_variants/variant_with_record_extension.rb
54
59
  - lib/active_storage/async_variants/variation_extension.rb
55
60
  - lib/active_storage/async_variants/version.rb