Class: ActiveStorage::AwsRecord::Transaction

Inherits:
Object
  • Object
show all
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_items call.

100
FIBER_KEY =
:active_storage_aws_record_transaction

Class Method Summary collapse

Instance Method Summary collapse

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

.currentTransaction?

Returns the transaction active on this fiber.

Returns:

  • (Transaction, nil)

    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.

Parameters:

  • model (Class)

    the Attachment class that opened the transaction.



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 refcount ADD per 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.message}", @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.message}", @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(attachment)
  @destroys << attachment
end