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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +55 -0
- data/LICENSE +21 -0
- data/PLAN.md +413 -0
- data/README.md +287 -0
- data/lib/active_storage/aws_record/attachable.rb +104 -0
- data/lib/active_storage/aws_record/attachment.rb +426 -0
- data/lib/active_storage/aws_record/blob.rb +417 -0
- data/lib/active_storage/aws_record/configuration.rb +62 -0
- data/lib/active_storage/aws_record/item.rb +86 -0
- data/lib/active_storage/aws_record/owner.rb +82 -0
- data/lib/active_storage/aws_record/persistence.rb +89 -0
- data/lib/active_storage/aws_record/railtie.rb +53 -0
- data/lib/active_storage/aws_record/relation.rb +163 -0
- data/lib/active_storage/aws_record/schema.rb +148 -0
- data/lib/active_storage/aws_record/tables.rb +82 -0
- data/lib/active_storage/aws_record/tasks.rake +15 -0
- data/lib/active_storage/aws_record/transaction.rb +132 -0
- data/lib/active_storage/aws_record/variant_record.rb +208 -0
- data/lib/active_storage/aws_record/version.rb +7 -0
- data/lib/active_storage/aws_record.rb +148 -0
- data/lib/activestorage-aws-record.rb +4 -0
- metadata +166 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
require 'active_storage/aws_record/item'
|
|
2
|
+
require 'active_storage/aws_record/relation'
|
|
3
|
+
require 'active_storage/aws_record/transaction'
|
|
4
|
+
|
|
5
|
+
module ActiveStorage
|
|
6
|
+
module AwsRecord
|
|
7
|
+
# The join item between an owner record and a blob. Stored under the owner's
|
|
8
|
+
# partition (+ns#Owner#<record_type>#<record_id>+) with a sort key of
|
|
9
|
+
# +ns#Attachment#<name>#<id>+, so loading an owner's attachments (the
|
|
10
|
+
# contract's hot path) is a single base-table query. Creating/destroying an
|
|
11
|
+
# attachment also adjusts a strongly-consistent reference count on its blob,
|
|
12
|
+
# so a shared blob is only purged once no attachment references it.
|
|
13
|
+
class Attachment
|
|
14
|
+
include ActiveStorage::AwsRecord::Item
|
|
15
|
+
|
|
16
|
+
string_attr :id, database_attribute_name: 'as_id'
|
|
17
|
+
string_attr :record_type, database_attribute_name: 'as_record_type'
|
|
18
|
+
string_attr :record_id, database_attribute_name: 'as_record_id'
|
|
19
|
+
string_attr :name, database_attribute_name: 'as_name'
|
|
20
|
+
string_attr :blob_id, database_attribute_name: 'as_blob_id'
|
|
21
|
+
string_attr :created_at, database_attribute_name: 'as_created_at'
|
|
22
|
+
string_attr :entity, database_attribute_name: 'as_entity', default_value: 'Attachment'
|
|
23
|
+
|
|
24
|
+
# Transient flags Active Storage's change objects set on attachments; not
|
|
25
|
+
# persisted to DynamoDB.
|
|
26
|
+
attr_accessor :pending_upload, :immediate_variants_processed
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# Open a real, fiber-local DynamoDB transaction (see {Transaction}). The
|
|
30
|
+
# generic +has_many+ clear/replace/detach paths wrap their per-row
|
|
31
|
+
# destroys in this; buffering them into one +transact_write_items+ makes a
|
|
32
|
+
# multi-attachment change atomic, instead of deleting some rows before a
|
|
33
|
+
# later one fails. Nested calls join the enclosing transaction. *Only
|
|
34
|
+
# destroys are buffered* — creates stay synchronous so Active Storage's own
|
|
35
|
+
# failed-save cleanup still fires.
|
|
36
|
+
def transaction(&block)
|
|
37
|
+
ActiveStorage::AwsRecord::Transaction.run(self, &block)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Atomic +ADD+ on a blob's reference count by +blob_id+, guarded on the
|
|
41
|
+
# blob item still existing (so a stale/purged blob is never resurrected by
|
|
42
|
+
# the ADD). Shared by the per-row path and the batched commit (which
|
|
43
|
+
# coalesces one update per distinct blob).
|
|
44
|
+
def blob_count_update(blob_id, delta)
|
|
45
|
+
blob_keys = ActiveStorage::AwsRecord::Blob.logical_keys_for(blob_id)
|
|
46
|
+
{
|
|
47
|
+
table_name: ActiveStorage::AwsRecord::Blob.table_name,
|
|
48
|
+
key: ActiveStorage::AwsRecord::Blob.physical_key(**blob_keys),
|
|
49
|
+
update_expression: 'ADD #c :delta',
|
|
50
|
+
condition_expression: 'attribute_exists(#h)',
|
|
51
|
+
expression_attribute_names: { '#c' => 'as_attachments_count', '#h' => schema.partition_attr },
|
|
52
|
+
expression_attribute_values: { ':delta' => delta },
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def find_by(attributes)
|
|
57
|
+
ActiveStorage::AwsRecord::Relation.new(self).find_by(attributes)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def where(attributes = nil)
|
|
61
|
+
ActiveStorage::AwsRecord::Relation.new(self).where(attributes)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The owner-adjacency partition key for a (record_type, record_id) pair.
|
|
65
|
+
def owner_partition(record_type, record_id)
|
|
66
|
+
ns_key('Owner', record_type, record_id)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The sort-key prefix for an owner's attachments, optionally narrowed to a
|
|
70
|
+
# single attachment +name+, with a trailing separator so +begins_with+
|
|
71
|
+
# cannot bleed across names.
|
|
72
|
+
def attachment_prefix(name = nil)
|
|
73
|
+
base = name ? ns_key('Attachment', name) : ns_key('Attachment')
|
|
74
|
+
"#{base}#{ActiveStorage::AwsRecord.config.separator}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Empty relation used by +Blob#attachments+ (see Blob): returns nothing
|
|
78
|
+
# for a non-persisted blob, and refuses to materialize on a persisted one
|
|
79
|
+
# (no blob→attachment index exists by design).
|
|
80
|
+
def none_for_blob(persisted)
|
|
81
|
+
NullBlobRelation.new(persisted)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def initialize(attributes = {})
|
|
86
|
+
super()
|
|
87
|
+
attributes = attributes.dup
|
|
88
|
+
record = attributes.delete(:record) || attributes.delete('record')
|
|
89
|
+
blob = attributes.delete(:blob) || attributes.delete('blob')
|
|
90
|
+
self.id ||= generate_uuid
|
|
91
|
+
assign_attributes(attributes) if attributes.any?
|
|
92
|
+
self.record = record if record
|
|
93
|
+
self.blob = blob if blob
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def assign_attributes(attributes)
|
|
97
|
+
attributes = attributes.dup
|
|
98
|
+
record = attributes.delete(:record) || attributes.delete('record')
|
|
99
|
+
blob = attributes.delete(:blob) || attributes.delete('blob')
|
|
100
|
+
attributes.each { |name, value| public_send("#{name}=", value) }
|
|
101
|
+
self.record = record if record
|
|
102
|
+
self.blob = blob if blob
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def logical_keys
|
|
106
|
+
{
|
|
107
|
+
h: self.class.owner_partition(record_type, record_id),
|
|
108
|
+
r: ns_key('Attachment', name, id),
|
|
109
|
+
item_id: ns_key('Attachment', id),
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def record=(record)
|
|
114
|
+
@record = record
|
|
115
|
+
self.record_type = ActiveStorage::Attached::Changes.polymorphic_name(record)
|
|
116
|
+
self.record_id = record.id.to_s
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Resolve the owner from the stored (record_type, record_id). Prefer the
|
|
120
|
+
# +active_storage_find+ hook ({Attachable}/{Owner} owners define it so an
|
|
121
|
+
# aws-record model with a key-hash +#find+ resolves correctly), and fall
|
|
122
|
+
# back to the generic contract's bare-id +find(record_id)+ (the gem's own
|
|
123
|
+
# +Blob+/+VariantRecord+ owners override that).
|
|
124
|
+
def record
|
|
125
|
+
@record ||= begin
|
|
126
|
+
owner_class = record_type.constantize
|
|
127
|
+
if owner_class.respond_to?(:active_storage_find)
|
|
128
|
+
owner_class.active_storage_find(record_id)
|
|
129
|
+
else
|
|
130
|
+
owner_class.find(record_id)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def blob=(blob)
|
|
136
|
+
@blob = blob
|
|
137
|
+
self.blob_id = blob&.id
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def blob
|
|
141
|
+
@blob ||= ActiveStorage::AwsRecord::Blob.find(blob_id)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def signed_id
|
|
145
|
+
blob.signed_id
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Persist the join item. On create, atomically (DynamoDB transaction) put
|
|
149
|
+
# the attachment and increment its blob's reference count — guarding the
|
|
150
|
+
# increment on the blob still existing so a purged blob is never resurrected.
|
|
151
|
+
def save!(opts = {})
|
|
152
|
+
self.created_at ||= ActiveStorage::AwsRecord::Blob.current_timestamp
|
|
153
|
+
if new_record?
|
|
154
|
+
stamp_physical_keys!
|
|
155
|
+
transactional_create!
|
|
156
|
+
end
|
|
157
|
+
# A persisted attachment join row is immutable (record/blob/name are
|
|
158
|
+
# fixed at creation), so a re-save is a deliberate no-op. Calling super
|
|
159
|
+
# here would invoke aws-record's #save!, which delegates to #save — and
|
|
160
|
+
# because we override #save to call #save!, that recurses infinitely
|
|
161
|
+
# (SystemStackError). Active Storage re-saves already-persisted
|
|
162
|
+
# attachments during its attach flow, so this path is hot.
|
|
163
|
+
self
|
|
164
|
+
rescue Aws::DynamoDB::Errors::TransactionCanceledException, Aws::DynamoDB::Errors::ConditionalCheckFailedException,
|
|
165
|
+
Aws::Record::Errors::ConditionalWriteFailed, Aws::Record::Errors::ValidationError => e
|
|
166
|
+
raise ActiveStorage::RecordNotSaved.new(e.message, self)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def save(opts = {})
|
|
170
|
+
save!(opts)
|
|
171
|
+
true
|
|
172
|
+
rescue ActiveStorage::RecordNotSaved
|
|
173
|
+
false
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Delete the join item and decrement its blob's reference count atomically.
|
|
177
|
+
# Must complete or raise (never return false) for the generic
|
|
178
|
+
# dependent-destroy path; a failure maps to +RecordNotDestroyed+.
|
|
179
|
+
def destroy
|
|
180
|
+
@previously_persisted = persisted?
|
|
181
|
+
enqueue_or_destroy if @previously_persisted
|
|
182
|
+
true
|
|
183
|
+
rescue Aws::DynamoDB::Errors::ServiceError => e
|
|
184
|
+
# ServiceError, not Errors::Error: every DynamoDB error (throttling,
|
|
185
|
+
# conditional failure, a re-raised cancellation) subclasses ServiceError,
|
|
186
|
+
# while Errors::Error has no subclasses — rescuing it would catch nothing.
|
|
187
|
+
raise ActiveStorage::RecordNotDestroyed.new("Failed to destroy attachment: #{e.message}", self)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Like #destroy (it also decrements the blob count, so the count never
|
|
191
|
+
# drifts), but used by replace/detach paths that manage the blob
|
|
192
|
+
# themselves; skips +touch+/blob cleanup.
|
|
193
|
+
def delete
|
|
194
|
+
@previously_persisted = persisted?
|
|
195
|
+
enqueue_or_destroy if @previously_persisted
|
|
196
|
+
true
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def previously_persisted?
|
|
200
|
+
@previously_persisted
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# --- Internal API used by {Transaction} when batching destroys ----------
|
|
204
|
+
|
|
205
|
+
# Flush a single buffered destroy: identical to a non-transactional destroy,
|
|
206
|
+
# so its idempotent duplicate-purge / orphaned-blob recovery is preserved
|
|
207
|
+
# (the batched ≥2 path cannot offer per-row recovery).
|
|
208
|
+
def commit_destroy!
|
|
209
|
+
transactional_destroy!
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The +Delete+ action (without the +{ delete: }+ wrapper) for this
|
|
213
|
+
# attachment's row, guarded so a row that already vanished cancels the
|
|
214
|
+
# transaction rather than silently masking a concurrent change.
|
|
215
|
+
def delete_transact_item
|
|
216
|
+
{
|
|
217
|
+
table_name: self.class.table_name,
|
|
218
|
+
key: physical_key,
|
|
219
|
+
condition_expression: 'attribute_exists(#h)',
|
|
220
|
+
expression_attribute_names: { '#h' => schema.partition_attr },
|
|
221
|
+
}
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Purge the attachment and (if unreferenced) its blob. Assumes no *ambient*
|
|
225
|
+
# +Attachment.transaction+ is open: the +destroy+ here commits at the end of
|
|
226
|
+
# its own block, so the refcount is decremented before +blob.purge+ checks
|
|
227
|
+
# it. Nested inside an outer transaction the decrement would not have
|
|
228
|
+
# committed yet, so the blob's foreign-key guard would (harmlessly) refuse
|
|
229
|
+
# the purge — the contract never calls purge from inside a transaction.
|
|
230
|
+
def purge
|
|
231
|
+
self.class.transaction do
|
|
232
|
+
destroy
|
|
233
|
+
touch_record
|
|
234
|
+
end
|
|
235
|
+
blob&.purge
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# See {#purge}: assumes no ambient +Attachment.transaction+.
|
|
239
|
+
def purge_later
|
|
240
|
+
self.class.transaction do
|
|
241
|
+
destroy
|
|
242
|
+
touch_record
|
|
243
|
+
end
|
|
244
|
+
blob&.purge_later
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Upload the io to the service and (optionally) analyze, mirroring the
|
|
248
|
+
# reference backend's attachment upload lifecycle.
|
|
249
|
+
def uploaded(io:)
|
|
250
|
+
blob.local_io = io
|
|
251
|
+
blob.analyze_without_saving unless blob.analyzed? || skip_later_analysis?
|
|
252
|
+
io.rewind if io.respond_to?(:rewind)
|
|
253
|
+
blob.upload_without_unfurling(io)
|
|
254
|
+
blob.save! if blob.persisted?
|
|
255
|
+
blob.mirror_later
|
|
256
|
+
blob.analyze_later unless blob.analyzed? || skip_later_analysis?
|
|
257
|
+
ensure
|
|
258
|
+
blob.local_io = nil
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def variant(transformations)
|
|
262
|
+
blob.variant(transformations_by_name(transformations))
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def preview(transformations)
|
|
266
|
+
blob.preview(transformations_by_name(transformations))
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def representation(transformations)
|
|
270
|
+
blob.representation(transformations_by_name(transformations))
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def as_json(options = nil)
|
|
274
|
+
{ id: id, name: name, record_type: record_type, record_id: record_id, blob_id: blob_id }.as_json(options)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
delegate_missing_to :blob
|
|
278
|
+
|
|
279
|
+
private
|
|
280
|
+
|
|
281
|
+
# Buffer this destroy into the ambient +Attachment.transaction+ (so a
|
|
282
|
+
# multi-row clear/replace/detach commits atomically), or, outside one,
|
|
283
|
+
# delete immediately through the per-row path.
|
|
284
|
+
def enqueue_or_destroy
|
|
285
|
+
if (tx = ActiveStorage::AwsRecord::Transaction.current)
|
|
286
|
+
tx.enqueue_destroy(self)
|
|
287
|
+
else
|
|
288
|
+
transactional_destroy!
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def transactional_create!
|
|
293
|
+
dynamodb_client.transact_write_items(
|
|
294
|
+
transact_items: [
|
|
295
|
+
{ put: {
|
|
296
|
+
table_name: self.class.table_name,
|
|
297
|
+
item: save_values,
|
|
298
|
+
condition_expression: 'attribute_not_exists(#h)',
|
|
299
|
+
expression_attribute_names: { '#h' => schema.partition_attr },
|
|
300
|
+
} },
|
|
301
|
+
{ update: blob_count_update(1) },
|
|
302
|
+
]
|
|
303
|
+
)
|
|
304
|
+
mark_persisted!
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def transactional_destroy!
|
|
308
|
+
dynamodb_client.transact_write_items(
|
|
309
|
+
transact_items: [
|
|
310
|
+
{ delete: delete_transact_item },
|
|
311
|
+
{ update: blob_count_update(-1) },
|
|
312
|
+
]
|
|
313
|
+
)
|
|
314
|
+
mark_destroyed!
|
|
315
|
+
rescue Aws::DynamoDB::Errors::TransactionCanceledException => e
|
|
316
|
+
handle_destroy_cancellation(e)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# In a transaction any failed condition cancels everything, so decide what
|
|
320
|
+
# to redo from the per-item cancellation reasons:
|
|
321
|
+
# * attachment row already gone (duplicate purge) → idempotent no-op;
|
|
322
|
+
# * blob already purged → the +ADD -1+ would have created a zombie blob, so
|
|
323
|
+
# the transaction is rejected by its +attribute_exists+ guard; just delete
|
|
324
|
+
# the orphaned attachment row on its own.
|
|
325
|
+
def handle_destroy_cancellation(error)
|
|
326
|
+
reasons = error.cancellation_reasons || []
|
|
327
|
+
attachment_failed = reasons[0]&.code == 'ConditionalCheckFailed'
|
|
328
|
+
blob_failed = reasons[1]&.code == 'ConditionalCheckFailed'
|
|
329
|
+
|
|
330
|
+
if attachment_failed
|
|
331
|
+
mark_destroyed!
|
|
332
|
+
elsif blob_failed
|
|
333
|
+
delete!
|
|
334
|
+
mark_destroyed!
|
|
335
|
+
else
|
|
336
|
+
raise
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# This attachment's blob refcount +ADD+ (delegates to the class helper so the
|
|
341
|
+
# batched commit path can coalesce one update per distinct blob).
|
|
342
|
+
def blob_count_update(delta)
|
|
343
|
+
self.class.blob_count_update(blob_id, delta)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def touch_record
|
|
347
|
+
record.touch if record.respond_to?(:touch) && record.respond_to?(:persisted?) && record.persisted?
|
|
348
|
+
rescue ActiveStorage::RecordNotFound
|
|
349
|
+
nil
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def reflection
|
|
353
|
+
record_type.constantize.attachment_reflections[name]
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def named_variants
|
|
357
|
+
reflection&.named_variants || {}
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def transformations_by_name(transformations)
|
|
361
|
+
case transformations
|
|
362
|
+
when Symbol
|
|
363
|
+
variant_name = transformations
|
|
364
|
+
named_variants.fetch(variant_name) do
|
|
365
|
+
raise ArgumentError, "Cannot find variant :#{variant_name} for #{record_type}##{name}"
|
|
366
|
+
end.transformations
|
|
367
|
+
else
|
|
368
|
+
transformations
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def analyze_option
|
|
373
|
+
reflection&.options&.fetch(:analyze, nil)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def skip_later_analysis?
|
|
377
|
+
(analyze_option || ActiveStorage.analyze) == :lazily
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Empty relation backing +Blob#attachments+.
|
|
381
|
+
class NullBlobRelation
|
|
382
|
+
include Enumerable
|
|
383
|
+
|
|
384
|
+
def initialize(persisted)
|
|
385
|
+
@persisted = persisted
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def each(&block)
|
|
389
|
+
guard!
|
|
390
|
+
[].each(&block)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def find(*)
|
|
394
|
+
guard!
|
|
395
|
+
nil
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def to_a
|
|
399
|
+
guard!
|
|
400
|
+
[]
|
|
401
|
+
end
|
|
402
|
+
alias_method :to_ary, :to_a
|
|
403
|
+
|
|
404
|
+
def any?
|
|
405
|
+
guard!
|
|
406
|
+
false
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def empty?
|
|
410
|
+
guard!
|
|
411
|
+
true
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
private
|
|
415
|
+
|
|
416
|
+
def guard!
|
|
417
|
+
return false unless @persisted
|
|
418
|
+
|
|
419
|
+
raise ActiveStorage::QueryNotSupported,
|
|
420
|
+
'Blob#attachments is unsupported on a persisted blob in the single-table backend ' \
|
|
421
|
+
'(there is no blob→attachment index by design).'
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|