Class: ActiveStorage::AwsRecord::Transaction
- Inherits:
-
Object
- Object
- ActiveStorage::AwsRecord::Transaction
- Defined in:
- lib/active_storage/aws_record/transaction.rb
Overview
A fiber-local accumulator that batches the attachment destroys opened inside an Attachment.transaction block into a single DynamoDB transact_write_items, so a multi-attachment clear / replace / detach is atomic: DynamoDB deletes every row (and adjusts every blob refcount) or none, instead of deleting some rows before a later one fails (the partial-clear bug the generic has_many paths would otherwise hit).
*Creates are intentionally not buffered.* Active Storage’s own create paths (CreateOne/CreateMany) clean up newly-built blob/attachment records when a save! raises synchronously; deferring those writes to commit would move the failure past that cleanup. Only destroys — whose sole failure mode is partial multi-row deletion — are batched here.
Fiber-safe (Falcon): the active context lives in Fiber[], never a class or thread global, so independent request fibers (Falcon assigns one per request) each get their own transaction. The grouped-destroy blocks are executed synchronously on one fiber; Fiber[] is inherited by a child fiber, so do not spawn an Async task / Fiber.new inside an Attachment.transaction block (it would join the parent’s buffer rather than open its own).
Constant Summary collapse
- MAX_TRANSACT_ITEMS =
DynamoDB’s hard ceiling on actions in one
transact_write_itemscall. 100- FIBER_KEY =
:active_storage_aws_record_transaction
Class Method Summary collapse
-
.current ⇒ Transaction?
The transaction active on this fiber.
-
.run(model) ⇒ Object
Run
blockinside a transaction.
Instance Method Summary collapse
-
#commit! ⇒ Object
Flush the buffered destroys: * 0 → nothing; * 1 → the existing per-row path (
#commit_destroy!), which keeps its idempotent duplicate-purge / orphaned-blob recovery; * ≥2 → one coalescedtransact_write_items(one delete per attachment row, one refcountADDper distinct blob, since DynamoDB forbids two actions on the same item in a transaction). -
#enqueue_destroy(attachment) ⇒ Object
Buffer one attachment destroy/delete for the commit.
-
#initialize(model) ⇒ Transaction
constructor
A new instance of Transaction.
Constructor Details
#initialize(model) ⇒ Transaction
Returns a new instance of Transaction.
63 64 65 66 |
# File 'lib/active_storage/aws_record/transaction.rb', line 63 def initialize(model) @model = model @destroys = [] end |
Class Method Details
.current ⇒ Transaction?
Returns the transaction active on this fiber.
37 38 39 |
# File 'lib/active_storage/aws_record/transaction.rb', line 37 def current Fiber[FIBER_KEY] end |
.run(model) ⇒ Object
Run block inside a transaction. A nested call joins the enclosing transaction — its buffered destroys flush only at the outermost commit. The outermost call commits the buffer on success; on any exception it discards the buffer unwritten, so the block is atomic (nothing is deleted). The fiber-local context is always cleared on the way out.
48 49 50 51 52 53 54 55 56 57 58 59 60 |
# File 'lib/active_storage/aws_record/transaction.rb', line 48 def run(model) return yield if current # nested: join the outer transaction tx = new(model) Fiber[FIBER_KEY] = tx begin result = yield tx.commit! result ensure Fiber[FIBER_KEY] = nil end end |
Instance Method Details
#commit! ⇒ Object
Flush the buffered destroys:
-
0 → nothing;
-
1 → the existing per-row path (
#commit_destroy!), which keeps its idempotent duplicate-purge / orphaned-blob recovery; -
≥2 → one coalesced
transact_write_items(one delete per attachment row, one refcountADDper distinct blob, since DynamoDB forbids two actions on the same item in a transaction).
80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 |
# File 'lib/active_storage/aws_record/transaction.rb', line 80 def commit! return if @destroys.empty? if @destroys.size == 1 @destroys.first.commit_destroy! return end items = transact_items if items.size > MAX_TRANSACT_ITEMS deletes = items.count { |item| item.key?(:delete) } raise TransactionTooLarge, "Atomic attachment change needs #{items.size} DynamoDB actions, over the " \ "#{MAX_TRANSACT_ITEMS}-action transaction limit (#{deletes} attachments, " \ "#{items.size - deletes} blobs). Split the change into smaller batches." end @model.dynamodb_client.transact_write_items(transact_items: items) @destroys.each(&:mark_destroyed!) rescue Aws::DynamoDB::Errors::TransactionCanceledException => e # A conditional failure cancels the *whole* batch (a row or blob vanished # under a concurrent change), so nothing was written. Surface it as a # destroy failure; the generic path resets its deferred purges and # re-raises. Refusing a partial result here is the point of batching. raise ActiveStorage::RecordNotDestroyed.new( "Failed to destroy #{@destroys.size} attachments atomically: #{e.}", @destroys.first ) rescue Aws::DynamoDB::Errors::ServiceError => e # Any other DynamoDB failure (throttling, network) also wrote nothing; # map it to RecordNotDestroyed too, matching the per-row #destroy contract. raise ActiveStorage::RecordNotDestroyed.new("Failed to destroy attachment: #{e.}", @destroys.first) end |
#enqueue_destroy(attachment) ⇒ Object
Buffer one attachment destroy/delete for the commit.
69 70 71 |
# File 'lib/active_storage/aws_record/transaction.rb', line 69 def enqueue_destroy() @destroys << end |