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,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