familia 1.2.1 → 2.0.0.pre2
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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +68 -0
- data/.github/workflows/docs.yml +64 -0
- data/.gitignore +4 -0
- data/.pre-commit-config.yaml +3 -1
- data/.rubocop.yml +16 -9
- data/.rubocop_todo.yml +177 -31
- data/.yardopts +9 -0
- data/CLAUDE.md +141 -0
- data/Gemfile +16 -2
- data/Gemfile.lock +97 -36
- data/README.md +39 -23
- data/bin/irb +3 -0
- data/docs/connection_pooling.md +192 -0
- data/familia.gemspec +10 -6
- data/lib/familia/base.rb +19 -9
- data/lib/familia/connection.rb +232 -65
- data/lib/familia/core_ext.rb +1 -1
- data/lib/familia/datatype/commands.rb +59 -0
- data/lib/familia/{redistype → datatype}/serialization.rb +9 -13
- data/lib/familia/{redistype → datatype}/types/hashkey.rb +25 -25
- data/lib/familia/{redistype → datatype}/types/list.rb +13 -13
- data/lib/familia/{redistype → datatype}/types/sorted_set.rb +20 -20
- data/lib/familia/{redistype → datatype}/types/string.rb +22 -21
- data/lib/familia/{redistype → datatype}/types/unsorted_set.rb +11 -11
- data/lib/familia/datatype.rb +243 -0
- data/lib/familia/errors.rb +5 -2
- data/lib/familia/features/expiration.rb +33 -34
- data/lib/familia/features/quantization.rb +9 -3
- data/lib/familia/features/safe_dump.rb +2 -3
- data/lib/familia/features.rb +2 -2
- data/lib/familia/horreum/class_methods.rb +97 -110
- data/lib/familia/horreum/commands.rb +46 -51
- data/lib/familia/horreum/connection.rb +82 -0
- data/lib/familia/horreum/{relations_management.rb → related_fields_management.rb} +37 -35
- data/lib/familia/horreum/serialization.rb +61 -198
- data/lib/familia/horreum/settings.rb +6 -17
- data/lib/familia/horreum/utils.rb +11 -10
- data/lib/familia/horreum.rb +69 -60
- data/lib/familia/logging.rb +12 -12
- data/lib/familia/multi_result.rb +72 -0
- data/lib/familia/refinements.rb +7 -44
- data/lib/familia/settings.rb +11 -11
- data/lib/familia/utils.rb +123 -90
- data/lib/familia/version.rb +4 -21
- data/lib/familia.rb +18 -13
- data/lib/middleware/database_middleware.rb +150 -0
- data/try/configuration/scenarios_try.rb +65 -0
- data/try/core/connection_try.rb +58 -0
- data/try/core/errors_try.rb +93 -0
- data/try/core/extensions_try.rb +26 -0
- data/try/{10_familia_try.rb → core/familia_extended_try.rb} +11 -10
- data/try/{00_familia_try.rb → core/familia_try.rb} +7 -5
- data/try/core/middleware_try.rb +68 -0
- data/try/core/refinements_try.rb +39 -0
- data/try/core/settings_try.rb +76 -0
- data/try/core/tools_try.rb +54 -0
- data/try/core/utils_try.rb +189 -0
- data/try/{26_redis_bool_try.rb → datatypes/boolean_try.rb} +4 -2
- data/try/datatypes/datatype_base_try.rb +69 -0
- data/try/{25_redis_type_hash_try.rb → datatypes/hash_try.rb} +5 -3
- data/try/{23_redis_type_list_try.rb → datatypes/list_try.rb} +5 -3
- data/try/{22_redis_type_set_try.rb → datatypes/set_try.rb} +5 -3
- data/try/{21_redis_type_zset_try.rb → datatypes/sorted_set_try.rb} +6 -4
- data/try/{24_redis_type_string_try.rb → datatypes/string_try.rb} +8 -8
- data/try/edge_cases/empty_identifiers_try.rb +48 -0
- data/try/{92_symbolize_try.rb → edge_cases/hash_symbolization_try.rb} +12 -7
- data/try/edge_cases/json_serialization_try.rb +85 -0
- data/try/edge_cases/race_conditions_try.rb +60 -0
- data/try/edge_cases/reserved_keywords_try.rb +59 -0
- data/try/{93_string_coercion_try.rb → edge_cases/string_coercion_try.rb} +60 -59
- data/try/edge_cases/ttl_side_effects_try.rb +51 -0
- data/try/features/expiration_try.rb +86 -0
- data/try/features/quantization_try.rb +90 -0
- data/try/{35_feature_safedump_try.rb → features/safe_dump_advanced_try.rb} +7 -6
- data/try/features/safe_dump_try.rb +137 -0
- data/try/{test_helpers.rb → helpers/test_helpers.rb} +25 -60
- data/try/{27_redis_horreum_try.rb → horreum/base_try.rb} +39 -14
- data/try/horreum/class_methods_try.rb +41 -0
- data/try/horreum/commands_try.rb +49 -0
- data/try/{29_redis_horreum_initialization_try.rb → horreum/initialization_try.rb} +9 -7
- data/try/horreum/relations_try.rb +146 -0
- data/try/{28_redis_horreum_serialization_try.rb → horreum/serialization_try.rb} +13 -11
- data/try/horreum/settings_try.rb +43 -0
- data/try/integration/cross_component_try.rb +46 -0
- data/try/{41_customer_safedump_try.rb → models/customer_safe_dump_try.rb} +9 -7
- data/try/{40_customer_try.rb → models/customer_try.rb} +21 -18
- data/try/models/datatype_base_try.rb +100 -0
- data/try/{30_familia_object_try.rb → models/familia_object_try.rb} +18 -16
- data/try/performance/benchmarks_try.rb +55 -0
- data/try/pooling/README.md +20 -0
- data/try/pooling/configurable_stress_test_try.rb +435 -0
- data/try/pooling/connection_pool_test_try.rb +273 -0
- data/try/pooling/lib/atomic_saves_v3_connection_pool_helpers.rb +192 -0
- data/try/pooling/lib/connection_pool_metrics.rb +372 -0
- data/try/pooling/lib/connection_pool_stress_test.rb +959 -0
- data/try/pooling/lib/connection_pool_threading_models.rb +421 -0
- data/try/pooling/lib/visualize_stress_results.rb +434 -0
- data/try/pooling/pool_siege_try.rb +509 -0
- data/try/pooling/run_stress_tests_try.rb +482 -0
- data/try/prototypes/atomic_saves_v1_context_proxy.rb +121 -0
- data/try/prototypes/atomic_saves_v2_connection_switching.rb +161 -0
- data/try/prototypes/atomic_saves_v3_connection_pool.rb +189 -0
- data/try/prototypes/atomic_saves_v4.rb +105 -0
- data/try/prototypes/lib/atomic_saves_v2_connection_switching_helpers.rb +124 -0
- data/try/prototypes/lib/atomic_saves_v3_connection_pool_helpers.rb +192 -0
- metadata +143 -46
- data/.github/workflows/ruby.yml +0 -71
- data/VERSION.yml +0 -4
- data/lib/familia/redistype/commands.rb +0 -59
- data/lib/familia/redistype.rb +0 -228
- data/lib/familia/tools.rb +0 -68
- data/lib/redis_middleware.rb +0 -109
- data/try/20_redis_type_try.rb +0 -70
- data/try/91_json_bug_try.rb +0 -86
@@ -0,0 +1,161 @@
|
|
1
|
+
# try/prototypes/atomic_saves_v2_connection_switching.rb
|
2
|
+
|
3
|
+
# try -vf try/prototypes/atomic_saves_v2_connection_switching.rb
|
4
|
+
|
5
|
+
require 'bundler/setup'
|
6
|
+
require 'securerandom'
|
7
|
+
|
8
|
+
require_relative '../helpers/test_helpers'
|
9
|
+
require_relative 'lib/atomic_saves_v2_connection_switching_helpers'
|
10
|
+
|
11
|
+
# Familia.debug = false
|
12
|
+
|
13
|
+
## Clean database before tests
|
14
|
+
BankAccount.dbclient.flushdb
|
15
|
+
#=> "OK"
|
16
|
+
|
17
|
+
## Test 1: Basic atomic save - create accounts
|
18
|
+
@account1 = BankAccount.new(balance: 1000, holder_name: "Alice")
|
19
|
+
@account2 = BankAccount.new(balance: 500, holder_name: "Bob")
|
20
|
+
[@account1.balance, @account2.balance]
|
21
|
+
#=> [1000.0, 500.0]
|
22
|
+
|
23
|
+
## Test 1: Save initial state
|
24
|
+
[@account1.save, @account2.save]
|
25
|
+
#=> [true, true]
|
26
|
+
|
27
|
+
## Test 1: Perform atomic transfer
|
28
|
+
@results = Familia.atomic do
|
29
|
+
@account1.withdraw(200)
|
30
|
+
@account2.deposit(200)
|
31
|
+
@account1.save
|
32
|
+
@account2.save
|
33
|
+
end
|
34
|
+
@results.class
|
35
|
+
#=> Array
|
36
|
+
|
37
|
+
## Test 1: Verify transfer completed atomically
|
38
|
+
@account1.refresh!
|
39
|
+
@account2.refresh!
|
40
|
+
[@account1.balance, @account2.balance]
|
41
|
+
#=> [800.0, 700.0]
|
42
|
+
|
43
|
+
## Test 2: Failed atomic operation - create accounts
|
44
|
+
@account3 = BankAccount.new(balance: 100, holder_name: "Charlie")
|
45
|
+
@account4 = BankAccount.new(balance: 500, holder_name: "Dave")
|
46
|
+
[@account3.balance, @account4.balance]
|
47
|
+
#=> [100.0, 500.0]
|
48
|
+
|
49
|
+
## Test 2: Save initial state
|
50
|
+
[@account3.save, @account4.save]
|
51
|
+
#=> [true, true]
|
52
|
+
|
53
|
+
## Test 2: Attempt atomic operation that should fail
|
54
|
+
begin
|
55
|
+
Familia.atomic do
|
56
|
+
@account3.withdraw(200)
|
57
|
+
@account4.deposit(200)
|
58
|
+
@account3.save
|
59
|
+
@account4.save
|
60
|
+
end
|
61
|
+
false
|
62
|
+
rescue => e
|
63
|
+
e.message
|
64
|
+
end
|
65
|
+
#=> "Insufficient funds"
|
66
|
+
|
67
|
+
## Test 2: Verify rollback - balances unchanged
|
68
|
+
@account3.refresh!
|
69
|
+
@account4.refresh!
|
70
|
+
[@account3.balance, @account4.balance]
|
71
|
+
#=> [100.0, 500.0]
|
72
|
+
|
73
|
+
## Test 3: Complex atomic operation - create accounts
|
74
|
+
@sender = BankAccount.new(balance: 1500, holder_name: "Eve")
|
75
|
+
@receiver = BankAccount.new(balance: 200, holder_name: "Frank")
|
76
|
+
[@sender.save, @receiver.save]
|
77
|
+
#=> [true, true]
|
78
|
+
|
79
|
+
## Test 3: Setup transfer amount
|
80
|
+
@transfer_amount = 750
|
81
|
+
@transfer_amount
|
82
|
+
#=> 750
|
83
|
+
|
84
|
+
## Test 3: Perform complex atomic operation with transaction record
|
85
|
+
@results2 = Familia.atomic do
|
86
|
+
@txn = TransactionRecord.new(
|
87
|
+
from: @sender.account_number,
|
88
|
+
to: @receiver.account_number,
|
89
|
+
amount: @transfer_amount
|
90
|
+
)
|
91
|
+
|
92
|
+
@sender.withdraw(@transfer_amount)
|
93
|
+
@receiver.deposit(@transfer_amount)
|
94
|
+
@txn.status = "completed"
|
95
|
+
|
96
|
+
[@sender.save, @receiver.save, @txn.save]
|
97
|
+
end
|
98
|
+
@results2.class
|
99
|
+
#=> Array
|
100
|
+
|
101
|
+
## Test 3: Verify all changes were applied
|
102
|
+
@sender.refresh!
|
103
|
+
@receiver.refresh!
|
104
|
+
[@sender.balance, @receiver.balance]
|
105
|
+
#=> [750.0, 950.0]
|
106
|
+
|
107
|
+
## Test 3: Verify transaction record was saved
|
108
|
+
@txn_key = @txn.dbkey
|
109
|
+
@saved_txn = TransactionRecord.deserialize_value(@txn_key)
|
110
|
+
@saved_txn.status
|
111
|
+
#=> "completed"
|
112
|
+
|
113
|
+
## Test 4: Nested context behavior - create account
|
114
|
+
@account5 = BankAccount.new(balance: 1000, holder_name: "Grace")
|
115
|
+
@account5.save
|
116
|
+
#=> true
|
117
|
+
|
118
|
+
## Test 4: Perform nested atomic operations
|
119
|
+
@results3 = Familia.atomic do
|
120
|
+
@account5.deposit(100)
|
121
|
+
@account5.save
|
122
|
+
|
123
|
+
Familia.atomic do
|
124
|
+
@account5.deposit(50)
|
125
|
+
@account5.save
|
126
|
+
end
|
127
|
+
end
|
128
|
+
@results3.class
|
129
|
+
#=> Array
|
130
|
+
|
131
|
+
## Test 4: Verify nested operations worked transparently
|
132
|
+
@account5.refresh!
|
133
|
+
@account5.balance
|
134
|
+
#=> 1150.0
|
135
|
+
|
136
|
+
## Test 5: Batch update within atomic context - create account
|
137
|
+
@account6 = BankAccount.new(balance: 500, holder_name: "Henry")
|
138
|
+
@account6.save
|
139
|
+
#=> true
|
140
|
+
|
141
|
+
## Test 5: Perform batch update in atomic context
|
142
|
+
@results4 = Familia.atomic do
|
143
|
+
@account6.batch_update(
|
144
|
+
balance: 600.0,
|
145
|
+
holder_name: "Henry Jr."
|
146
|
+
)
|
147
|
+
|
148
|
+
@txn2 = TransactionRecord.new(
|
149
|
+
from: "system",
|
150
|
+
to: @account6.account_number,
|
151
|
+
amount: 100
|
152
|
+
)
|
153
|
+
@txn2.save
|
154
|
+
end
|
155
|
+
@results4.class
|
156
|
+
#=> Array
|
157
|
+
|
158
|
+
## Test 5: Verify batch update and transaction creation
|
159
|
+
@account6.refresh!
|
160
|
+
[@account6.balance, @account6.holder_name]
|
161
|
+
#=> [600.0, "Henry Jr."]
|
@@ -0,0 +1,189 @@
|
|
1
|
+
# try/prototypes/atomic_saves_v3_connection_pool.rb
|
2
|
+
|
3
|
+
# try -vf try/prototypes/atomic_saves_v3_connection_pool.rb
|
4
|
+
|
5
|
+
# re: Test 4, calling refresh! inside of an existing transation.
|
6
|
+
# The issue is that refresh! is being called within the transaction, but
|
7
|
+
# Database MULTI transactions queue commands and don't return results until
|
8
|
+
# EXEC. So refresh! inside the transaction isn't going to see the current
|
9
|
+
# state from Redis.
|
10
|
+
#
|
11
|
+
# The problem is more fundamental: Database MULTI/EXEC transactions don't
|
12
|
+
# work the way this code expects them to. In Redis:
|
13
|
+
#
|
14
|
+
# 1. MULTI starts queuing commands
|
15
|
+
# 2. All subsequent commands are queued, not executed
|
16
|
+
# 3. EXEC executes all queued commands atomically
|
17
|
+
#
|
18
|
+
# But this code is trying to:
|
19
|
+
# 1. Call refresh! (which does a GET) inside the transaction - this won't
|
20
|
+
# work as expected
|
21
|
+
# 2. Read the current balance and modify it - this won't work inside MULTI
|
22
|
+
#
|
23
|
+
# The atomic operations need to be restructured to work with Redis's
|
24
|
+
# actual transaction model. Let me fix this:
|
25
|
+
|
26
|
+
require 'bundler/setup'
|
27
|
+
require 'securerandom'
|
28
|
+
require 'thread'
|
29
|
+
|
30
|
+
require_relative '../helpers/test_helpers'
|
31
|
+
require_relative 'lib/atomic_saves_v3_connection_pool_helpers'
|
32
|
+
|
33
|
+
# Familia.debug = false
|
34
|
+
|
35
|
+
## Clean database before tests
|
36
|
+
BankAccount.dbclient.flushdb
|
37
|
+
#=> "OK"
|
38
|
+
|
39
|
+
## Test 1: Basic atomic operation with proxy approach
|
40
|
+
@account1 = BankAccount.new(balance: 1000, holder_name: "Alice")
|
41
|
+
@account2 = BankAccount.new(balance: 500, holder_name: "Bob")
|
42
|
+
[@account1.save, @account2.save]
|
43
|
+
#=> [true, true]
|
44
|
+
|
45
|
+
## Test 1: Proxy approach atomic transfer
|
46
|
+
@proxy_results = Familia.atomic do
|
47
|
+
@account1.withdraw(200)
|
48
|
+
@account2.deposit(200)
|
49
|
+
@account1.save
|
50
|
+
@account2.save
|
51
|
+
end
|
52
|
+
@proxy_results.class
|
53
|
+
#=> Array
|
54
|
+
|
55
|
+
## Test 1: Verify proxy approach worked
|
56
|
+
@account1.refresh!
|
57
|
+
@account2.refresh!
|
58
|
+
[@account1.balance, @account2.balance]
|
59
|
+
#=> [800.0, 700.0]
|
60
|
+
|
61
|
+
## Test 2: Explicit connection approach
|
62
|
+
@account3 = BankAccount.new(balance: 1500, holder_name: "Charlie")
|
63
|
+
@account4 = BankAccount.new(balance: 300, holder_name: "Dave")
|
64
|
+
[@account3.save, @account4.save]
|
65
|
+
#=> [true, true]
|
66
|
+
|
67
|
+
## Test 2: Explicit approach atomic transfer
|
68
|
+
@explicit_results = Familia.atomic_explicit do |conn|
|
69
|
+
@account3.withdraw(500)
|
70
|
+
@account4.deposit(500)
|
71
|
+
@account3.save(using: conn)
|
72
|
+
@account4.save(using: conn)
|
73
|
+
end
|
74
|
+
@explicit_results.class
|
75
|
+
#=> Array
|
76
|
+
|
77
|
+
## Test 2: Verify explicit approach worked
|
78
|
+
@account3.refresh!
|
79
|
+
@account4.refresh!
|
80
|
+
[@account3.balance, @account4.balance]
|
81
|
+
#=> [1000.0, 800.0]
|
82
|
+
|
83
|
+
## Test 3: Nested transactions create separate transactions
|
84
|
+
@account5 = BankAccount.new(balance: 2000, holder_name: "Eve")
|
85
|
+
@account5.save
|
86
|
+
#=> true
|
87
|
+
|
88
|
+
## Test 3: Nested atomic operations (should be separate)
|
89
|
+
@nested_results = Familia.atomic do
|
90
|
+
@account5.deposit(100)
|
91
|
+
@account5.save
|
92
|
+
|
93
|
+
# This should be a separate transaction
|
94
|
+
Familia.atomic do
|
95
|
+
@account5.deposit(200)
|
96
|
+
@account5.save
|
97
|
+
end
|
98
|
+
end
|
99
|
+
@nested_results.class
|
100
|
+
#=> Array
|
101
|
+
|
102
|
+
## Test 3: Verify nested operations both executed
|
103
|
+
@account5.refresh!
|
104
|
+
@account5.balance
|
105
|
+
#=> 2300.0
|
106
|
+
|
107
|
+
## Test 4: Concurrent operations test (thread safety)
|
108
|
+
@shared_account = BankAccount.new(balance: 10000, holder_name: "Shared")
|
109
|
+
@shared_account.save
|
110
|
+
#=> true
|
111
|
+
|
112
|
+
## Test 4: Run concurrent atomic operations
|
113
|
+
@threads = []
|
114
|
+
@results_array = []
|
115
|
+
@mutex = Mutex.new
|
116
|
+
|
117
|
+
5.times do |i|
|
118
|
+
@threads << Thread.new do
|
119
|
+
result = Familia.atomic do
|
120
|
+
# Each thread performs an atomic operation
|
121
|
+
@shared_account.refresh!
|
122
|
+
current_balance = @shared_account.balance
|
123
|
+
@shared_account.balance = current_balance.to_f - 100
|
124
|
+
@shared_account.save
|
125
|
+
end
|
126
|
+
|
127
|
+
@mutex.synchronize { @results_array << result }
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
# Wait for all threads to complete
|
132
|
+
@threads.each(&:join)
|
133
|
+
@results_array.size
|
134
|
+
#=> 5
|
135
|
+
|
136
|
+
## Test 4: Verify concurrent operations worked correctly
|
137
|
+
@shared_account.refresh!
|
138
|
+
@shared_account.balance
|
139
|
+
#=> 9500.0
|
140
|
+
|
141
|
+
## Test 5: Connection pool behavior verification
|
142
|
+
@initial_pool_size = Familia.connection_pool.size
|
143
|
+
@initial_pool_size
|
144
|
+
#=> 10
|
145
|
+
|
146
|
+
## Test 5: Connection pool with multiple operations
|
147
|
+
@pool_test_results = []
|
148
|
+
3.times do |i|
|
149
|
+
@pool_test_results << Familia.atomic do
|
150
|
+
account = BankAccount.new(balance: 1000, holder_name: "Pool#{i}")
|
151
|
+
account.save
|
152
|
+
end
|
153
|
+
end
|
154
|
+
@pool_test_results.size
|
155
|
+
#=> 3
|
156
|
+
|
157
|
+
## Test 6: Error handling and rollback
|
158
|
+
@error_account = BankAccount.new(balance: 100, holder_name: "ErrorTest")
|
159
|
+
@error_account.save
|
160
|
+
#=> true
|
161
|
+
|
162
|
+
## Test 6: Atomic operation that should fail and rollback
|
163
|
+
begin
|
164
|
+
Familia.atomic do
|
165
|
+
@error_account.withdraw(200) # Should fail
|
166
|
+
@error_account.save
|
167
|
+
end
|
168
|
+
false
|
169
|
+
rescue => e
|
170
|
+
e.message
|
171
|
+
end
|
172
|
+
#=> "Insufficient funds"
|
173
|
+
|
174
|
+
## Test 6: Verify account balance unchanged after error
|
175
|
+
@error_account.refresh!
|
176
|
+
@error_account.balance
|
177
|
+
#=> 100.0
|
178
|
+
|
179
|
+
## Summary: Connection Pool Integration Results
|
180
|
+
#
|
181
|
+
# ✅ Proxy approach works with connection pooling
|
182
|
+
# ✅ Explicit approach provides clear transaction boundaries
|
183
|
+
# ✅ Nested transactions create separate transactions (as intended)
|
184
|
+
# ✅ Thread safety handled automatically by connection pool
|
185
|
+
# ✅ Error handling and rollback work correctly
|
186
|
+
# ✅ Connection pool manages resources efficiently
|
187
|
+
#
|
188
|
+
# Key Finding: Connection pool handles thread safety automatically
|
189
|
+
# No special thread-safety code needed - just proper pool integration
|
@@ -0,0 +1,105 @@
|
|
1
|
+
# try/prototypes/atomic_saves_v4.rb
|
2
|
+
|
3
|
+
class BankAccount < Familia::Horreum
|
4
|
+
class_sorted_set :relatable_object_ids
|
5
|
+
|
6
|
+
identifier_field :account_number
|
7
|
+
field :account_number
|
8
|
+
field :balance
|
9
|
+
field :foreign_balance
|
10
|
+
field :holder_name
|
11
|
+
|
12
|
+
string :metadata # A separate dbkey, to store JSON blog
|
13
|
+
|
14
|
+
def init
|
15
|
+
@account_number ||= SecureRandom.hex(8)
|
16
|
+
@balance = @balance.to_f if @balance
|
17
|
+
@foreign_balance = @foreign_balance.to_f if @foreign_balance
|
18
|
+
@metadata = @metadata.is_a?(String) ? JSON.parse(@metadata) : @metadata
|
19
|
+
end
|
20
|
+
|
21
|
+
def balance
|
22
|
+
@balance&.to_f
|
23
|
+
end
|
24
|
+
|
25
|
+
def withdraw(amount)
|
26
|
+
raise "Insufficient funds" if balance < amount
|
27
|
+
self.balance -= amount
|
28
|
+
end
|
29
|
+
|
30
|
+
def deposit(amount)
|
31
|
+
# This is PURPOSE3, for complex updates for objects that already exist.
|
32
|
+
self.transaction do
|
33
|
+
self.balance += amount
|
34
|
+
self.foreign_balance = balance * 1.25 # exchange rate
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def an_example_that_we_do_not_want
|
39
|
+
# This is how multi worked in Familia before this project. This
|
40
|
+
# is what we are trying to avoid by not using blocks. However,
|
41
|
+
# it's not the block that is the issue; it's losing the object
|
42
|
+
# oriented `self.fieldname = value` syntax and having to manually
|
43
|
+
# resort to functional programming.
|
44
|
+
dbclient.multi do |multi|
|
45
|
+
multi.del(dbkey)
|
46
|
+
# Also remove from the class-level values, :display_domains, :owners
|
47
|
+
multi.zrem(V2::CustomDomain.values.dbkey, identifier)
|
48
|
+
multi.hdel(V2::CustomDomain.display_domains.dbkey, display_domain)
|
49
|
+
multi.hdel(V2::CustomDomain.owners.dbkey, display_domain)
|
50
|
+
multi.del(brand.dbkey)
|
51
|
+
multi.del(logo.dbkey)
|
52
|
+
multi.del(icon.dbkey)
|
53
|
+
unless customer.nil?
|
54
|
+
multi.zrem(customer.custom_domains.dbkey, display_domain)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def metadata=(value)
|
60
|
+
@metadata = value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value
|
61
|
+
end
|
62
|
+
|
63
|
+
class << self
|
64
|
+
def create(account_number, holder_name, metadata = {})
|
65
|
+
|
66
|
+
attrs = {
|
67
|
+
account_number: account_number,
|
68
|
+
balance: 0.0,
|
69
|
+
holder_name: holder_name,
|
70
|
+
metadata: metadata
|
71
|
+
}
|
72
|
+
|
73
|
+
# By convention, we would not write any code that runs on initialization
|
74
|
+
# that has any database operations. However if we did, they would still work
|
75
|
+
# the would just run immediately and not with the following transaction.
|
76
|
+
accnt = new attrs
|
77
|
+
|
78
|
+
Familia.transaction do
|
79
|
+
|
80
|
+
# Inside this block, `accnt.dbclient` returns the open multi connection.
|
81
|
+
#
|
82
|
+
# Anything that calls database commands, will be queues on the multi
|
83
|
+
# connection, like attr.metadata = {...}. So neither the main object
|
84
|
+
# key or the separate `metadata` string key will update unless both
|
85
|
+
# succeed. This is PURPOSE1.
|
86
|
+
accnt.save
|
87
|
+
|
88
|
+
# PROBLEM: what is returned here when we call accnt.class.dbclient? where
|
89
|
+
# does a class level method like `add` get its db connection from then?
|
90
|
+
#
|
91
|
+
# This is PURPOSE2, a major reason for implementing transactions. We want
|
92
|
+
# to prevent our relatable_object_ids index from being updated if the
|
93
|
+
# account save fails.
|
94
|
+
add accnt
|
95
|
+
|
96
|
+
# Transaction method returns the block return
|
97
|
+
accnt
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def add(accnt)
|
102
|
+
relatable_object_ids.add Time.now.to_f, accnt.identifier
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
# try/prototypes/lib/atomic_saves_v2_connection_switching_helpers.rb
|
2
|
+
|
3
|
+
##
|
4
|
+
# Atomic Save V2 Proof of Concept - Connection Switching Approach
|
5
|
+
#
|
6
|
+
# This implementation demonstrates atomic saves across multiple Familia
|
7
|
+
# objects by switching which Database connection the `dbclient` method returns
|
8
|
+
# based on transaction context.
|
9
|
+
#
|
10
|
+
# Key Features:
|
11
|
+
# 1. **Connection Switching**: The `dbclient` method returns either normal
|
12
|
+
# connection or MULTI connection based on Thread-local context
|
13
|
+
# 2. **Thread Safety**: Uses Thread-local storage for transaction state
|
14
|
+
# 3. **No method_missing**: Clean implementation via method overriding
|
15
|
+
# 4. **Database MULTI/EXEC**: Leverages Redis's native transaction support
|
16
|
+
#
|
17
|
+
# Design Decision: TransactionalMethods Module REMOVED
|
18
|
+
#
|
19
|
+
# We prefer that nested `Familia.atomic` calls create separate transactions
|
20
|
+
# rather than being merged into the parent transaction. This provides clearer
|
21
|
+
# transaction boundaries and more predictable behavior:
|
22
|
+
#
|
23
|
+
# Familia.atomic do
|
24
|
+
# account.save # Transaction 1
|
25
|
+
#
|
26
|
+
# Familia.atomic do
|
27
|
+
# account.batch_update() # Transaction 2 (separate)
|
28
|
+
# end
|
29
|
+
# end
|
30
|
+
#
|
31
|
+
# This approach avoids the complexity of the TransactionalMethods module
|
32
|
+
# and makes transaction scope explicit and predictable.
|
33
|
+
|
34
|
+
|
35
|
+
# Test models first - define before any module modifications
|
36
|
+
class BankAccount < Familia::Horreum
|
37
|
+
identifier_field :account_number
|
38
|
+
field :account_number
|
39
|
+
field :balance
|
40
|
+
field :holder_name
|
41
|
+
|
42
|
+
def initialize(account_number: nil, balance: 0, holder_name: nil)
|
43
|
+
@account_number = account_number || SecureRandom.hex(8)
|
44
|
+
@balance = balance.to_f
|
45
|
+
@holder_name = holder_name
|
46
|
+
end
|
47
|
+
|
48
|
+
def withdraw(amount)
|
49
|
+
raise "Insufficient funds" if balance < amount
|
50
|
+
self.balance -= amount
|
51
|
+
end
|
52
|
+
|
53
|
+
def deposit(amount)
|
54
|
+
self.balance += amount
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
class TransactionRecord < Familia::Horreum
|
59
|
+
identifier_field :transaction_id
|
60
|
+
field :transaction_id
|
61
|
+
field :from_account
|
62
|
+
field :to_account
|
63
|
+
field :amount
|
64
|
+
field :status
|
65
|
+
field :created_at
|
66
|
+
|
67
|
+
def initialize(from: nil, to: nil, amount: 0)
|
68
|
+
@transaction_id = SecureRandom.hex(8)
|
69
|
+
@from_account = from
|
70
|
+
@to_account = to
|
71
|
+
@amount = amount.to_f
|
72
|
+
@status = "pending"
|
73
|
+
@created_at = Time.now.to_i
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# Atomic Save V2 - Connection-switching approach
|
78
|
+
module Familia
|
79
|
+
class << self
|
80
|
+
def current_transaction
|
81
|
+
Thread.current[:familia_current_transaction]
|
82
|
+
end
|
83
|
+
|
84
|
+
def current_transaction=(transaction)
|
85
|
+
Thread.current[:familia_current_transaction] = transaction
|
86
|
+
end
|
87
|
+
|
88
|
+
def atomic(&block)
|
89
|
+
if current_transaction
|
90
|
+
# Already in a transaction, just execute the block
|
91
|
+
yield
|
92
|
+
else
|
93
|
+
# Use Database multi with block form
|
94
|
+
dbclient.multi do |multi|
|
95
|
+
begin
|
96
|
+
self.current_transaction = multi
|
97
|
+
yield
|
98
|
+
ensure
|
99
|
+
self.current_transaction = nil
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
# Override the dbclient method in both base classes
|
107
|
+
module TransactionalDatabase
|
108
|
+
def dbclient
|
109
|
+
Familia.current_transaction || super
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# Inject into Horreum - TransactionalMethods module removed per design decision
|
114
|
+
class Horreum
|
115
|
+
module Serialization
|
116
|
+
prepend TransactionalDatabase
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
# Inject into DataType
|
121
|
+
class DataType
|
122
|
+
prepend TransactionalDatabase
|
123
|
+
end
|
124
|
+
end
|