activestorage-aws-record 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,208 @@
1
+ require 'base64'
2
+ require 'active_storage/aws_record/item'
3
+ require 'active_storage/aws_record/owner'
4
+
5
+ module ActiveStorage
6
+ module AwsRecord
7
+ # Tracks a processed variant of a blob when
8
+ # +config.active_storage.track_variants+ is on. It is co-located in its
9
+ # blob's partition (+ns#Blob#<blob_id>+) with a sort key of
10
+ # +ns#VariantRecord#<variation_digest>+, so the (blob_id, variation_digest)
11
+ # pair is unique by construction and the blob and all its variants form one
12
+ # item collection. It is itself an attachment owner (+has_one_attached
13
+ # :image+); its owner +id+ is a reversible, +#+-free encoding of the natural
14
+ # key so +find(id)+ resolves it.
15
+ class VariantRecord
16
+ include ActiveStorage::AwsRecord::Item
17
+ include ActiveStorage::AwsRecord::Owner
18
+
19
+ ID_DELIMITER = ' '
20
+
21
+ # Raised internally when the variant row already exists (the conditional
22
+ # +Put+ lost the race), so create_or_find_by! can fall back to +find+ —
23
+ # while genuine post-write failures (e.g. the image attachment) propagate.
24
+ class CreateConflict < StandardError; end
25
+
26
+ string_attr :blob_id, database_attribute_name: 'as_blob_id'
27
+ string_attr :variation_digest, database_attribute_name: 'as_variation_digest'
28
+ string_attr :entity, database_attribute_name: 'as_entity', default_value: 'VariantRecord'
29
+
30
+ class << self
31
+ # Contract +find(id)+: decode the reversible id back to the natural key.
32
+ def find(id)
33
+ blob_id, variation_digest = decode_id(id)
34
+ find_by(blob_id: blob_id, variation_digest: variation_digest) ||
35
+ raise(ActiveStorage::RecordNotFound, "Couldn't find #{name} with id=#{id.inspect}")
36
+ rescue ArgumentError
37
+ raise ActiveStorage::RecordNotFound, "Couldn't find #{name} with id=#{id.inspect}"
38
+ end
39
+
40
+ def find_by(blob_id:, variation_digest:)
41
+ keys = logical_keys_for(blob_id.to_s, variation_digest)
42
+ get_item(**keys)
43
+ end
44
+
45
+ # Owner resolution for Active Storage (VariantRecord is an attachment owner
46
+ # via +image+). Delegate to the reversible-id +#find+: {Attachable}'s default
47
+ # +find_with_opts(hash_key => id)+ adapter cannot address the composite key.
48
+ def active_storage_find(id)
49
+ find(id)
50
+ end
51
+
52
+ # Race-safe creation: a conditional put on the variant's own key, paired
53
+ # with a check that the source blob still exists, so a variant can never
54
+ # be created against a just-purged blob. A condition failure (the variant
55
+ # already exists, or the blob is gone) falls back to find. The block (used
56
+ # by VariantWithRecord to +image.attach+ the processed file) runs before
57
+ # save so the queued image attachment is flushed by the save callbacks.
58
+ def create_or_find_by!(blob_id:, variation_digest:)
59
+ record = new(blob_id: blob_id.to_s, variation_digest: variation_digest)
60
+ yield record if block_given?
61
+ record.save!
62
+ record
63
+ rescue CreateConflict
64
+ # Only a genuine create conflict (the row already exists) falls back to
65
+ # find — a post-write failure (image attachment, blob gone) propagates.
66
+ # NOTE: the marker row commits just before its image attachment is
67
+ # flushed, so a *concurrent* processor that loses the race here can
68
+ # briefly observe the record before its image is attached (a transient
69
+ # that resolves once the winner finishes; like Active Storage's
70
+ # create-then-upload, but without AR's single enclosing transaction).
71
+ find_by(blob_id: blob_id, variation_digest: variation_digest) ||
72
+ raise(ActiveStorage::RecordNotSaved.new('Failed to create or find variant record', record))
73
+ end
74
+
75
+ # All variant records for a blob (used by Blob#destroy's variant sweep).
76
+ # Mode A: strong base-table query under the blob partition. Mode B: GSI.
77
+ def where_blob(blob_id)
78
+ schema = ActiveStorage::AwsRecord.schema
79
+ partition = ns_key('Blob', blob_id)
80
+ prefix = "#{ns_key('VariantRecord')}#{ActiveStorage::AwsRecord.config.separator}"
81
+ opts = {
82
+ key_condition_expression: '#h = :h AND begins_with(#r, :r)',
83
+ expression_attribute_values: { ':h' => partition, ':r' => prefix },
84
+ }
85
+ if schema.range_mode?
86
+ opts[:expression_attribute_names] = { '#h' => schema.partition_attr, '#r' => schema.sort_attr }
87
+ opts[:consistent_read] = true
88
+ else
89
+ opts[:index_name] = schema.index_name
90
+ opts[:expression_attribute_names] = { '#h' => schema.index_partition_attr, '#r' => schema.index_sort_attr }
91
+ end
92
+ query(opts).to_a
93
+ end
94
+
95
+ # Logical keys for a (blob_id, variation_digest) pair without an instance.
96
+ def logical_keys_for(blob_id, variation_digest)
97
+ {
98
+ h: ns_key('Blob', blob_id),
99
+ r: ns_key('VariantRecord', variation_digest),
100
+ item_id: ns_key('Blob', blob_id, 'VariantRecord', variation_digest),
101
+ }
102
+ end
103
+
104
+ def encode_id(blob_id, variation_digest)
105
+ Base64.urlsafe_encode64("#{blob_id}#{ID_DELIMITER}#{variation_digest}", padding: false)
106
+ end
107
+
108
+ def decode_id(id)
109
+ Base64.urlsafe_decode64(id).split(ID_DELIMITER, 2)
110
+ end
111
+ end
112
+
113
+ def logical_keys
114
+ self.class.logical_keys_for(blob_id, variation_digest)
115
+ end
116
+
117
+ # Persist the variant row transactionally *and* run the owner save/commit
118
+ # callbacks, so a queued +image+ attachment (from create_or_find_by!'s
119
+ # block) is saved and uploaded. Overrides Owner#save! to substitute the
120
+ # transactional create for aws-record's plain put. If a step *after* the row
121
+ # write fails (e.g. the image attachment), the half-written row is removed
122
+ # so it does not become a markerless "poison" record.
123
+ def save!(opts = {})
124
+ marker_written = false
125
+ completed = run_callbacks(:save) do
126
+ stamp_physical_keys!
127
+ if new_record?
128
+ transactional_create!
129
+ marker_written = true
130
+ end
131
+ true
132
+ end
133
+ raise ActiveStorage::RecordNotSaved.new('Save halted by a before_save callback', self) unless completed
134
+
135
+ run_callbacks(:commit)
136
+ self
137
+ rescue CreateConflict
138
+ raise
139
+ rescue StandardError
140
+ destroy_marker! if marker_written
141
+ raise
142
+ end
143
+
144
+ def save(opts = {})
145
+ save!(opts)
146
+ true
147
+ rescue ActiveStorage::RecordNotSaved, CreateConflict
148
+ false
149
+ end
150
+
151
+ # The owner id used as +record_id+ for the +image+ attachment; reversible
152
+ # so VariantRecord.find(id) resolves the natural key.
153
+ def id
154
+ return nil if blob_id.nil? || variation_digest.nil?
155
+
156
+ self.class.encode_id(blob_id, variation_digest)
157
+ end
158
+
159
+ private
160
+
161
+ def transactional_create!
162
+ blob_keys = ActiveStorage::AwsRecord::Blob.logical_keys_for(blob_id)
163
+ dynamodb_client.transact_write_items(
164
+ transact_items: [
165
+ { put: {
166
+ table_name: self.class.table_name,
167
+ item: save_values,
168
+ condition_expression: 'attribute_not_exists(#h)',
169
+ expression_attribute_names: { '#h' => schema.partition_attr },
170
+ } },
171
+ { condition_check: {
172
+ table_name: ActiveStorage::AwsRecord::Blob.table_name,
173
+ key: ActiveStorage::AwsRecord::Blob.physical_key(**blob_keys),
174
+ condition_expression: 'attribute_exists(#h)',
175
+ expression_attribute_names: { '#h' => schema.partition_attr },
176
+ } },
177
+ ]
178
+ )
179
+ mark_persisted!
180
+ rescue Aws::DynamoDB::Errors::TransactionCanceledException => e
181
+ # reasons[0] = the variant Put, reasons[1] = the blob existence check.
182
+ reasons = e.cancellation_reasons || []
183
+ put_failed = reasons[0]&.code == 'ConditionalCheckFailed'
184
+ blob_failed = reasons[1]&.code == 'ConditionalCheckFailed'
185
+ # Only a pure put-conflict (variant exists, source blob still present) is
186
+ # a CreateConflict; if the blob is gone, surface a genuine failure.
187
+ raise CreateConflict if put_failed && !blob_failed
188
+
189
+ raise ActiveStorage::RecordNotSaved.new(e.message, self)
190
+ rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException, Aws::Record::Errors::ConditionalWriteFailed,
191
+ Aws::Record::Errors::ValidationError => e
192
+ raise ActiveStorage::RecordNotSaved.new(e.message, self)
193
+ end
194
+
195
+ # Remove a half-written variant row (best effort) so a post-write failure
196
+ # does not leave a markerless record that blocks regeneration. Also purge
197
+ # the image attachment/blob if it was already created during +after_save+,
198
+ # so it is not orphaned.
199
+ def destroy_marker!
200
+ image.purge if image.attached?
201
+ dynamodb_client.delete_item(table_name: self.class.table_name, key: physical_key)
202
+ mark_destroyed!
203
+ rescue StandardError
204
+ nil
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module AwsRecord
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,148 @@
1
+ require 'time'
2
+ require 'aws-record'
3
+ require 'globalid'
4
+ require 'active_support'
5
+ require 'active_support/core_ext/object/blank'
6
+ require 'active_model'
7
+ require 'active_storage'
8
+
9
+ require 'active_storage/aws_record/version'
10
+ require 'active_storage/aws_record/configuration'
11
+ require 'active_storage/aws_record/schema'
12
+
13
+ module ActiveStorage
14
+ # Active Storage *metadata* backend backed by Amazon DynamoDB through the
15
+ # +aws-record+ gem. Blob bytes flow through a normal Active Storage Service
16
+ # (Disk/S3); only blob/attachment/variant-record metadata lives in DynamoDB,
17
+ # in a single application-provided table (Single Table Design). See PLAN.md /
18
+ # README.md for the full design.
19
+ module AwsRecord
20
+ autoload :Persistence, 'active_storage/aws_record/persistence'
21
+ autoload :Item, 'active_storage/aws_record/item'
22
+ autoload :Attachable, 'active_storage/aws_record/attachable'
23
+ autoload :Owner, 'active_storage/aws_record/owner'
24
+ autoload :Relation, 'active_storage/aws_record/relation'
25
+ autoload :Transaction, 'active_storage/aws_record/transaction'
26
+ autoload :TransactionTooLarge, 'active_storage/aws_record/transaction'
27
+ autoload :Blob, 'active_storage/aws_record/blob'
28
+ autoload :Attachment, 'active_storage/aws_record/attachment'
29
+ autoload :VariantRecord, 'active_storage/aws_record/variant_record'
30
+ autoload :Tables, 'active_storage/aws_record/tables'
31
+
32
+ # Eager, load-time initialization keeps the shared mutable state fiber-safe
33
+ # under Falcon: the mutex exists before any fiber can race on the client, and
34
+ # the config/schema objects are created up front (never via +||=+).
35
+ @client_mutex = Mutex.new
36
+ @config = Configuration.new
37
+ @schema = nil
38
+ @dynamodb_client = nil
39
+
40
+ class << self
41
+ # @return [Configuration] the memoized gem configuration.
42
+ attr_reader :config
43
+
44
+ # @return [Schema, nil] the resolved table layout (set by {.install!}).
45
+ attr_reader :schema
46
+
47
+ # Yields {#config} for block-style configuration.
48
+ def configure
49
+ yield @config
50
+ end
51
+
52
+ # The shared +Aws::DynamoDB::Client+ every model uses. Built lazily but
53
+ # mutex-guarded so concurrent fibers cannot race two clients into existence.
54
+ #
55
+ # @return [Aws::DynamoDB::Client]
56
+ def dynamodb_client
57
+ @client_mutex.synchronize do
58
+ @dynamodb_client ||= @config.client || Aws::DynamoDB::Client.new(@config.client_options)
59
+ end
60
+ end
61
+
62
+ # Inject a client (tests / Railtie) and rewire the models to it.
63
+ def dynamodb_client=(client)
64
+ @client_mutex.synchronize { @dynamodb_client = client }
65
+ [Blob, Attachment, VariantRecord].each do |model|
66
+ model.configure_client(client: client) if model.respond_to?(:configure_client)
67
+ end
68
+ client
69
+ end
70
+
71
+ # Drop the memoized client/schema (tests, and the Railtie before re-boot).
72
+ def reset!
73
+ @client_mutex.synchronize do
74
+ @dynamodb_client = nil
75
+ @schema = nil
76
+ end
77
+ end
78
+
79
+ # Build a +#+-separated composite key, validating every segment is present
80
+ # (a blank segment would silently corrupt the key space). This is the gem's
81
+ # lightweight stand-in for an app-specific +compose_key+.
82
+ #
83
+ # @param parts [Array<#to_s>]
84
+ # @return [String]
85
+ def key(*parts)
86
+ separator = @config.separator
87
+ parts.each do |part|
88
+ raise ArgumentError, "key segment cannot be blank (parts: #{parts.inspect})" if part.nil? || part.to_s.empty?
89
+
90
+ if part.to_s.include?(separator)
91
+ raise ArgumentError, "key segment #{part.inspect} may not contain the separator #{separator.inspect} " \
92
+ "(parts: #{parts.inspect})"
93
+ end
94
+ end
95
+ parts.join(separator)
96
+ end
97
+
98
+ # Discover the table layout, declare the key attributes on the three models
99
+ # (now that their DB names are known), point them at the shared client, and
100
+ # set the table name. Idempotent; safe to call at every boot.
101
+ def install!
102
+ client = dynamodb_client
103
+ Tables.ensure! if @config.manage_table
104
+ @client_mutex.synchronize { @schema ||= Schema.discover(client, @config) }
105
+
106
+ [Blob, Attachment, VariantRecord].each do |model|
107
+ model.set_table_name(@config.table_name)
108
+ define_key_attributes!(model)
109
+ model.configure_client(client: client)
110
+ end
111
+ end
112
+
113
+ # Declare +has_one_attached+ on the gem's owner entities. Must run after
114
+ # Active Storage class indirection so the generic (non-AR) builder is used.
115
+ # Idempotent.
116
+ def install_attachments!
117
+ unless Blob.respond_to?(:reflect_on_attachment) && Blob.reflect_on_attachment(:preview_image)
118
+ Blob.has_one_attached :preview_image
119
+ end
120
+ unless VariantRecord.respond_to?(:reflect_on_attachment) && VariantRecord.reflect_on_attachment(:image)
121
+ VariantRecord.has_one_attached :image
122
+ end
123
+ end
124
+
125
+ private
126
+
127
+ # Declare the table's key attributes on a model using the discovered DB
128
+ # names. Ruby accessor names are gem-private (+dynamo_*+) so they never
129
+ # collide with entity attributes. In Mode B the numeric range key holds a
130
+ # constant and two extra string attributes carry the adjacency keys for the
131
+ # GSI.
132
+ def define_key_attributes!(model)
133
+ return if model.hash_key # already installed in this process
134
+
135
+ model.string_attr :dynamo_partition_key, hash_key: true, database_attribute_name: @schema.partition_attr
136
+ if @schema.range_mode?
137
+ model.string_attr :dynamo_range_key, range_key: true, database_attribute_name: @schema.sort_attr
138
+ else
139
+ model.integer_attr :dynamo_range_key, range_key: true, database_attribute_name: @schema.sort_attr
140
+ model.string_attr :dynamo_index_partition, database_attribute_name: @schema.index_partition_attr
141
+ model.string_attr :dynamo_index_sort, database_attribute_name: @schema.index_sort_attr
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+
148
+ require 'active_storage/aws_record/railtie' if defined?(Rails::Railtie)
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Gem entrypoint matching the gem name; delegates to the namespaced require.
4
+ require 'active_storage/aws_record'
metadata ADDED
@@ -0,0 +1,166 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activestorage-aws-record
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Thomas Witt
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activestorage
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '8.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activejob
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '8.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '8.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: activemodel
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '8.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '8.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: activesupport
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '8.1'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '8.1'
68
+ - !ruby/object:Gem::Dependency
69
+ name: aws-record
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '2.15'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '2.15'
82
+ - !ruby/object:Gem::Dependency
83
+ name: aws-sdk-dynamodb
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '1'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '1'
96
+ - !ruby/object:Gem::Dependency
97
+ name: globalid
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '1.0'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '1.0'
110
+ description: |
111
+ A metadata backend that lets Active Storage store its Blob, Attachment, and
112
+ VariantRecord rows in Amazon DynamoDB (through the aws-record gem) instead of
113
+ Active Record. Blob bytes still flow through any Active Storage Service
114
+ (Disk, S3, ...); only the metadata lives in DynamoDB. Implements the generic
115
+ custom Active Storage backend contract.
116
+ executables: []
117
+ extensions: []
118
+ extra_rdoc_files: []
119
+ files:
120
+ - CHANGELOG.md
121
+ - LICENSE
122
+ - PLAN.md
123
+ - README.md
124
+ - lib/active_storage/aws_record.rb
125
+ - lib/active_storage/aws_record/attachable.rb
126
+ - lib/active_storage/aws_record/attachment.rb
127
+ - lib/active_storage/aws_record/blob.rb
128
+ - lib/active_storage/aws_record/configuration.rb
129
+ - lib/active_storage/aws_record/item.rb
130
+ - lib/active_storage/aws_record/owner.rb
131
+ - lib/active_storage/aws_record/persistence.rb
132
+ - lib/active_storage/aws_record/railtie.rb
133
+ - lib/active_storage/aws_record/relation.rb
134
+ - lib/active_storage/aws_record/schema.rb
135
+ - lib/active_storage/aws_record/tables.rb
136
+ - lib/active_storage/aws_record/tasks.rake
137
+ - lib/active_storage/aws_record/transaction.rb
138
+ - lib/active_storage/aws_record/variant_record.rb
139
+ - lib/active_storage/aws_record/version.rb
140
+ - lib/activestorage-aws-record.rb
141
+ homepage: https://github.com/thomaswitt/activestorage-aws-record
142
+ licenses:
143
+ - MIT
144
+ metadata:
145
+ homepage_uri: https://github.com/thomaswitt/activestorage-aws-record
146
+ source_code_uri: https://github.com/thomaswitt/activestorage-aws-record
147
+ changelog_uri: https://github.com/thomaswitt/activestorage-aws-record/blob/main/CHANGELOG.md
148
+ rubygems_mfa_required: 'true'
149
+ rdoc_options: []
150
+ require_paths:
151
+ - lib
152
+ required_ruby_version: !ruby/object:Gem::Requirement
153
+ requirements:
154
+ - - ">="
155
+ - !ruby/object:Gem::Version
156
+ version: 3.4.0
157
+ required_rubygems_version: !ruby/object:Gem::Requirement
158
+ requirements:
159
+ - - ">="
160
+ - !ruby/object:Gem::Version
161
+ version: '0'
162
+ requirements: []
163
+ rubygems_version: 4.0.12
164
+ specification_version: 4
165
+ summary: Run Active Storage on Amazon DynamoDB via aws-record.
166
+ test_files: []