Class: ActiveItem::Transaction

Inherits:
Object
  • Object
show all
Defined in:
lib/active_item/transaction.rb

Overview

Wraps DynamoDB TransactWriteItems, allowing multiple put, update, and delete operations to be committed atomically (up to 100 items).

Supports two usage patterns:

  1. Explicit API (original):

    Model.transaction do |txn|
    txn.put(record1)
    txn.update(record2)
    end
    
  2. Implicit API (transactional saves):

    Model.transaction do
    record1.save!
    record2.save!
    record3.destroy!
    end
    

In the implicit API, save/destroy calls inside the block are automatically enrolled in the transaction and committed atomically at block end.

Constant Summary collapse

MAX_ITEMS =
100

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeTransaction

Returns a new instance of Transaction.



40
41
42
# File 'lib/active_item/transaction.rb', line 40

def initialize
  @operations = []
end

Instance Attribute Details

#operationsObject (readonly)

Returns the value of attribute operations.



27
28
29
# File 'lib/active_item/transaction.rb', line 27

def operations
  @operations
end

Class Method Details

.active?Boolean

Check if we're inside a transaction block

Returns:

  • (Boolean)


45
46
47
# File 'lib/active_item/transaction.rb', line 45

def self.active?
  !current.nil?
end

.currentObject

Thread-local storage for the current transaction context



31
32
33
# File 'lib/active_item/transaction.rb', line 31

def current
  Thread.current[:activeitem_current_transaction]
end

.current=(txn) ⇒ Object



35
36
37
# File 'lib/active_item/transaction.rb', line 35

def current=(txn)
  Thread.current[:activeitem_current_transaction] = txn
end

Instance Method Details

#delete(record) ⇒ Object



69
70
71
72
73
74
# File 'lib/active_item/transaction.rb', line 69

def delete(record)
  @operations << {
    op: { delete: { table_name: record.class.table_name, key: { record.class.primary_key.to_s => record.id } } },
    record: record, type: :delete
  }
end

#execute!Object



117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/active_item/transaction.rb', line 117

def execute!
  return if @operations.empty?
  raise TransactionError, "DynamoDB transactions are limited to #{MAX_ITEMS} items (got #{@operations.length})" if @operations.length > MAX_ITEMS

  transact_items = @operations.map { |o| o[:op] }
  client = @operations.first[:record].class.dynamodb
  client.transact_write_items(transact_items: transact_items)

  @operations.each do |o|
    o[:record].instance_variable_set(:@new_record, false) if o[:type] == :put
  end
rescue Aws::DynamoDB::Errors::TransactionCanceledException => e
  raise TransactionError, "Transaction cancelled: #{e.message}"
rescue Aws::DynamoDB::Errors::ValidationException => e
  raise TransactionError, "Transaction validation failed: #{e.message}"
end

#put(record, condition: nil) ⇒ Object



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/active_item/transaction.rb', line 49

def put(record, condition: nil)
  record.instance_variable_set(:@id, SecureRandom.uuid) unless record.id
  pk = record.class.primary_key
  record.instance_variable_set(:"@#{pk}", record.id) if pk != 'id'
  now = Time.now.utc.iso8601
  record.instance_variable_set(:@created_at, now) unless record.created_at
  record.instance_variable_set(:@updated_at, now)

  item = record.send(:build_dynamodb_item).merge(
    'createdAt' => record.created_at,
    'updatedAt' => record.updated_at,
    '_recent_pk' => 'ALL'
  )

  op = { put: { table_name: record.class.table_name, item: item } }
  op[:put][:condition_expression] = condition if condition

  @operations << { op: op, record: record, type: :put }
end

#update(record) ⇒ Object



76
77
78
79
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
112
113
114
115
# File 'lib/active_item/transaction.rb', line 76

def update(record)
  changes = record.changes
  return if changes.empty?

  set_parts = []
  remove_parts = []
  attr_values = {}
  attr_names = {}

  changes.each_with_index do |(field, (_old_val, new_val)), idx|
    dynamo_key = record.class.to_dynamo_key(field)
    if new_val.nil?
      remove_parts << "#f#{idx}"
      attr_names["#f#{idx}"] = dynamo_key
    else
      set_parts << "#f#{idx} = :v#{idx}"
      attr_names["#f#{idx}"] = dynamo_key
      attr_values[":v#{idx}"] = new_val
    end
  end

  set_parts << 'updatedAt = :ts'
  attr_values[':ts'] = Time.now.utc.iso8601

  update_expression = "SET #{set_parts.join(', ')}"
  update_expression += " REMOVE #{remove_parts.join(', ')}" if remove_parts.any?

  @operations << {
    op: {
      update: {
        table_name: record.class.table_name,
        key: { record.class.primary_key.to_s => record.id },
        update_expression: update_expression,
        expression_attribute_names: attr_names.any? ? attr_names : nil,
        expression_attribute_values: attr_values
      }.compact
    },
    record: record, type: :update
  }
end