-
Notifications
You must be signed in to change notification settings - Fork 68
/
locking.rb
192 lines (164 loc) · 6.1 KB
/
locking.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
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
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
# encoding: utf-8
module DoubleEntry
# Lock financial accounts to ensure consistency.
#
# In order to ensure financial transactions always keep track of balances
# consistently, database-level locking is needed. This module takes care of
# it.
#
# See DoubleEntry.lock_accounts and DoubleEntry.transfer for the public interface
# to this stuff.
#
# Locking is done on DoubleEntry::AccountBalance records. If an AccountBalance
# record for an account doesn't exist when you try to lock it, the locking
# code will create one.
#
# script/jack_hammer can be used to run concurrency tests on double_entry to
# validates that locking works properly.
module Locking
include Configurable
class Configuration
# Set this in your tests if you're using transactional_fixtures, so we know
# not to complain about a containing transaction when you call lock_accounts.
attr_accessor :running_inside_transactional_fixtures
def initialize #:nodoc:
@running_inside_transactional_fixtures = false
end
end
# Run the passed in block in a transaction with the given accounts locked for update.
#
# The transaction must be the outermost transaction to ensure data integrity. A
# LockMustBeOutermostTransaction will be raised if it isn't.
def self.lock_accounts(*accounts, &block)
lock = Lock.new(accounts)
if lock.in_a_locked_transaction?
lock.ensure_locked!
block.call
else
lock.perform_lock(&block)
end
rescue ActiveRecord::StatementInvalid => exception
if exception.message =~ /lock wait timeout/i
raise LockWaitTimeout
else
raise
end
end
# Return the account balance record for the given account name if there's a
# lock on it, or raise a LockNotHeld if there isn't.
def self.balance_for_locked_account(account)
Lock.new([account]).balance_for(account)
end
class Lock
@@locks = {}
def initialize(accounts)
# Make sure we always lock in the same order, to avoid deadlocks.
@accounts = accounts.flatten.sort
end
# Lock the given accounts, creating account balance records for them if
# needed.
def perform_lock(&block)
ensure_outermost_transaction!
unless lock_and_call(&block)
create_missing_account_balances
fail LockDisaster unless lock_and_call(&block)
end
end
# Return true if we're inside a lock_accounts block.
def in_a_locked_transaction?
!locks.nil?
end
def ensure_locked!
@accounts.each do |account|
unless lock?(account)
fail LockNotHeld, "No lock held for account: #{account.identifier}, scope #{account.scope}"
end
end
end
def balance_for(account)
ensure_locked!
locks[account]
end
private
def locks
@@locks[Thread.current.object_id]
end
def locks=(locks)
@@locks[Thread.current.object_id] = locks
end
def remove_locks
@@locks.delete(Thread.current.object_id)
end
# Return true if there's a lock on the given account.
def lock?(account)
in_a_locked_transaction? && locks.key?(account)
end
# Raise an exception unless we're outside any transactions.
def ensure_outermost_transaction!
minimum_transaction_level = Locking.configuration.running_inside_transactional_fixtures ? 1 : 0
unless AccountBalance.connection.open_transactions <= minimum_transaction_level
fail LockMustBeOutermostTransaction
end
end
# Start a transaction, grab locks on the given accounts, then call the block
# from within the transaction.
#
# If any account can't be locked (because there isn't a corresponding account
# balance record), don't call the block, and return false.
def lock_and_call
locks_succeeded = nil
AccountBalance.restartable_transaction do
locks_succeeded = AccountBalance.with_restart_on_deadlock { grab_locks }
if locks_succeeded
begin
yield
ensure
remove_locks
end
end
end
locks_succeeded
end
# Grab a lock on the account balance record for each account.
#
# If all the account balance records exist, set locks to a hash mapping
# accounts to account balances, and return true.
#
# If one or more account balance records don't exist, set
# accounts_with_balances to the corresponding accounts, and return false.
def grab_locks
account_balances = @accounts.map { |account| AccountBalance.find_by_account(account, lock: true) }
if account_balances.any?(&:nil?)
@accounts_without_balances = @accounts.zip(account_balances).
select { |_account, account_balance| account_balance.nil? }.
collect { |account, _account_balance| account }
false
else
self.locks = Hash[*@accounts.zip(account_balances).flatten]
true
end
end
# Create all the account_balances for the given accounts.
def create_missing_account_balances
@accounts_without_balances.each do |account|
# Get the initial balance from the lines table.
balance = account.balance
# Try to create the balance record, but ignore it if someone else has done it in the meantime.
AccountBalance.create_ignoring_duplicates!(account: account, balance: balance)
end
end
end
# Raised when lock_accounts is called inside an existing transaction.
class LockMustBeOutermostTransaction < RuntimeError
end
# Raised when attempting a transfer on an account that's not locked.
class LockNotHeld < RuntimeError
end
# Raised if things go horribly, horribly wrong. This should never happen.
class LockDisaster < RuntimeError
end
# Raised if waiting for locks times out.
class LockWaitTimeout < RuntimeError
end
end
end