decision_agent 0.3.0 → 1.1.0

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.
Files changed (220) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +234 -14
  3. data/lib/decision_agent/ab_testing/ab_test.rb +5 -1
  4. data/lib/decision_agent/ab_testing/ab_test_assignment.rb +2 -0
  5. data/lib/decision_agent/ab_testing/ab_test_manager.rb +2 -0
  6. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +2 -0
  7. data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +2 -13
  8. data/lib/decision_agent/ab_testing/storage/adapter.rb +2 -0
  9. data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +2 -0
  10. data/lib/decision_agent/agent.rb +78 -9
  11. data/lib/decision_agent/audit/adapter.rb +2 -0
  12. data/lib/decision_agent/audit/logger_adapter.rb +2 -0
  13. data/lib/decision_agent/audit/null_adapter.rb +2 -0
  14. data/lib/decision_agent/auth/access_audit_logger.rb +2 -0
  15. data/lib/decision_agent/auth/authenticator.rb +2 -0
  16. data/lib/decision_agent/auth/password_reset_manager.rb +2 -0
  17. data/lib/decision_agent/auth/password_reset_token.rb +2 -0
  18. data/lib/decision_agent/auth/permission.rb +2 -0
  19. data/lib/decision_agent/auth/permission_checker.rb +2 -0
  20. data/lib/decision_agent/auth/rbac_adapter.rb +2 -0
  21. data/lib/decision_agent/auth/rbac_config.rb +2 -0
  22. data/lib/decision_agent/auth/role.rb +2 -0
  23. data/lib/decision_agent/auth/session.rb +2 -0
  24. data/lib/decision_agent/auth/session_manager.rb +2 -0
  25. data/lib/decision_agent/auth/user.rb +2 -0
  26. data/lib/decision_agent/context.rb +14 -0
  27. data/lib/decision_agent/decision.rb +113 -4
  28. data/lib/decision_agent/dmn/adapter.rb +2 -0
  29. data/lib/decision_agent/dmn/cache.rb +2 -2
  30. data/lib/decision_agent/dmn/decision_graph.rb +7 -7
  31. data/lib/decision_agent/dmn/decision_tree.rb +16 -8
  32. data/lib/decision_agent/dmn/errors.rb +2 -0
  33. data/lib/decision_agent/dmn/exporter.rb +2 -0
  34. data/lib/decision_agent/dmn/feel/evaluator.rb +130 -114
  35. data/lib/decision_agent/dmn/feel/functions.rb +2 -0
  36. data/lib/decision_agent/dmn/feel/parser.rb +2 -0
  37. data/lib/decision_agent/dmn/feel/simple_parser.rb +98 -77
  38. data/lib/decision_agent/dmn/feel/transformer.rb +56 -102
  39. data/lib/decision_agent/dmn/feel/types.rb +2 -0
  40. data/lib/decision_agent/dmn/importer.rb +2 -0
  41. data/lib/decision_agent/dmn/model.rb +2 -4
  42. data/lib/decision_agent/dmn/parser.rb +2 -0
  43. data/lib/decision_agent/dmn/testing.rb +3 -2
  44. data/lib/decision_agent/dmn/validator.rb +5 -3
  45. data/lib/decision_agent/dmn/visualizer.rb +7 -6
  46. data/lib/decision_agent/dsl/condition_evaluator.rb +242 -1375
  47. data/lib/decision_agent/dsl/helpers/cache_helpers.rb +82 -0
  48. data/lib/decision_agent/dsl/helpers/comparison_helpers.rb +98 -0
  49. data/lib/decision_agent/dsl/helpers/date_helpers.rb +91 -0
  50. data/lib/decision_agent/dsl/helpers/geospatial_helpers.rb +85 -0
  51. data/lib/decision_agent/dsl/helpers/operator_evaluation_helpers.rb +160 -0
  52. data/lib/decision_agent/dsl/helpers/parameter_parsing_helpers.rb +206 -0
  53. data/lib/decision_agent/dsl/helpers/template_helpers.rb +39 -0
  54. data/lib/decision_agent/dsl/helpers/utility_helpers.rb +45 -0
  55. data/lib/decision_agent/dsl/operators/base.rb +70 -0
  56. data/lib/decision_agent/dsl/operators/basic_comparison_operators.rb +80 -0
  57. data/lib/decision_agent/dsl/operators/collection_operators.rb +60 -0
  58. data/lib/decision_agent/dsl/operators/date_arithmetic_operators.rb +206 -0
  59. data/lib/decision_agent/dsl/operators/date_time_operators.rb +47 -0
  60. data/lib/decision_agent/dsl/operators/duration_operators.rb +149 -0
  61. data/lib/decision_agent/dsl/operators/financial_operators.rb +237 -0
  62. data/lib/decision_agent/dsl/operators/geospatial_operators.rb +106 -0
  63. data/lib/decision_agent/dsl/operators/mathematical_operators.rb +234 -0
  64. data/lib/decision_agent/dsl/operators/moving_window_operators.rb +135 -0
  65. data/lib/decision_agent/dsl/operators/numeric_operators.rb +120 -0
  66. data/lib/decision_agent/dsl/operators/rate_operators.rb +65 -0
  67. data/lib/decision_agent/dsl/operators/statistical_aggregations.rb +187 -0
  68. data/lib/decision_agent/dsl/operators/string_aggregations.rb +84 -0
  69. data/lib/decision_agent/dsl/operators/string_operators.rb +72 -0
  70. data/lib/decision_agent/dsl/operators/time_component_operators.rb +72 -0
  71. data/lib/decision_agent/dsl/rule_parser.rb +2 -0
  72. data/lib/decision_agent/dsl/schema_validator.rb +37 -14
  73. data/lib/decision_agent/errors.rb +2 -0
  74. data/lib/decision_agent/evaluation.rb +14 -2
  75. data/lib/decision_agent/evaluators/base.rb +2 -0
  76. data/lib/decision_agent/evaluators/dmn_evaluator.rb +108 -19
  77. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +56 -11
  78. data/lib/decision_agent/evaluators/static_evaluator.rb +2 -0
  79. data/lib/decision_agent/explainability/condition_trace.rb +85 -0
  80. data/lib/decision_agent/explainability/explainability_result.rb +50 -0
  81. data/lib/decision_agent/explainability/rule_trace.rb +41 -0
  82. data/lib/decision_agent/explainability/trace_collector.rb +26 -0
  83. data/lib/decision_agent/monitoring/alert_manager.rb +7 -16
  84. data/lib/decision_agent/monitoring/dashboard_server.rb +383 -250
  85. data/lib/decision_agent/monitoring/metrics_collector.rb +2 -0
  86. data/lib/decision_agent/monitoring/monitored_agent.rb +2 -0
  87. data/lib/decision_agent/monitoring/prometheus_exporter.rb +3 -1
  88. data/lib/decision_agent/replay/replay.rb +4 -1
  89. data/lib/decision_agent/scoring/base.rb +2 -0
  90. data/lib/decision_agent/scoring/consensus.rb +2 -0
  91. data/lib/decision_agent/scoring/max_weight.rb +2 -0
  92. data/lib/decision_agent/scoring/threshold.rb +2 -0
  93. data/lib/decision_agent/scoring/weighted_average.rb +2 -0
  94. data/lib/decision_agent/simulation/errors.rb +20 -0
  95. data/lib/decision_agent/simulation/impact_analyzer.rb +500 -0
  96. data/lib/decision_agent/simulation/monte_carlo_simulator.rb +638 -0
  97. data/lib/decision_agent/simulation/replay_engine.rb +488 -0
  98. data/lib/decision_agent/simulation/scenario_engine.rb +320 -0
  99. data/lib/decision_agent/simulation/scenario_library.rb +165 -0
  100. data/lib/decision_agent/simulation/shadow_test_engine.rb +274 -0
  101. data/lib/decision_agent/simulation/what_if_analyzer.rb +1008 -0
  102. data/lib/decision_agent/simulation.rb +19 -0
  103. data/lib/decision_agent/testing/batch_test_importer.rb +6 -2
  104. data/lib/decision_agent/testing/batch_test_runner.rb +5 -2
  105. data/lib/decision_agent/testing/test_coverage_analyzer.rb +2 -0
  106. data/lib/decision_agent/testing/test_result_comparator.rb +2 -0
  107. data/lib/decision_agent/testing/test_scenario.rb +2 -0
  108. data/lib/decision_agent/version.rb +3 -1
  109. data/lib/decision_agent/versioning/activerecord_adapter.rb +108 -43
  110. data/lib/decision_agent/versioning/adapter.rb +9 -0
  111. data/lib/decision_agent/versioning/file_storage_adapter.rb +19 -6
  112. data/lib/decision_agent/versioning/version_manager.rb +9 -0
  113. data/lib/decision_agent/web/dmn_editor/serialization.rb +74 -0
  114. data/lib/decision_agent/web/dmn_editor/xml_builder.rb +107 -0
  115. data/lib/decision_agent/web/dmn_editor.rb +8 -67
  116. data/lib/decision_agent/web/middleware/auth_middleware.rb +2 -0
  117. data/lib/decision_agent/web/middleware/permission_middleware.rb +3 -1
  118. data/lib/decision_agent/web/public/app.js +186 -26
  119. data/lib/decision_agent/web/public/batch_testing.html +80 -6
  120. data/lib/decision_agent/web/public/dmn-editor.html +2 -2
  121. data/lib/decision_agent/web/public/dmn-editor.js +74 -8
  122. data/lib/decision_agent/web/public/index.html +69 -3
  123. data/lib/decision_agent/web/public/login.html +1 -1
  124. data/lib/decision_agent/web/public/sample_batch.csv +11 -0
  125. data/lib/decision_agent/web/public/sample_impact.csv +11 -0
  126. data/lib/decision_agent/web/public/sample_replay.csv +11 -0
  127. data/lib/decision_agent/web/public/sample_rules.json +118 -0
  128. data/lib/decision_agent/web/public/sample_shadow.csv +11 -0
  129. data/lib/decision_agent/web/public/sample_whatif.csv +11 -0
  130. data/lib/decision_agent/web/public/simulation.html +146 -0
  131. data/lib/decision_agent/web/public/simulation_impact.html +495 -0
  132. data/lib/decision_agent/web/public/simulation_replay.html +547 -0
  133. data/lib/decision_agent/web/public/simulation_shadow.html +561 -0
  134. data/lib/decision_agent/web/public/simulation_whatif.html +549 -0
  135. data/lib/decision_agent/web/public/styles.css +65 -0
  136. data/lib/decision_agent/web/public/users.html +1 -1
  137. data/lib/decision_agent/web/rack_helpers.rb +106 -0
  138. data/lib/decision_agent/web/rack_request_helpers.rb +196 -0
  139. data/lib/decision_agent/web/server.rb +2126 -1374
  140. data/lib/decision_agent.rb +19 -1
  141. data/lib/generators/decision_agent/install/install_generator.rb +2 -0
  142. data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +2 -0
  143. data/lib/generators/decision_agent/install/templates/ab_test_model.rb +2 -0
  144. data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +2 -0
  145. data/lib/generators/decision_agent/install/templates/migration.rb +2 -0
  146. data/lib/generators/decision_agent/install/templates/rule.rb +2 -0
  147. data/lib/generators/decision_agent/install/templates/rule_version.rb +2 -0
  148. metadata +103 -89
  149. data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
  150. data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
  151. data/spec/ab_testing/ab_test_spec.rb +0 -270
  152. data/spec/ab_testing/ab_testing_agent_spec.rb +0 -655
  153. data/spec/ab_testing/storage/adapter_spec.rb +0 -64
  154. data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
  155. data/spec/activerecord_thread_safety_spec.rb +0 -553
  156. data/spec/advanced_operators_spec.rb +0 -3150
  157. data/spec/agent_spec.rb +0 -289
  158. data/spec/api_contract_spec.rb +0 -430
  159. data/spec/audit_adapters_spec.rb +0 -92
  160. data/spec/auth/access_audit_logger_spec.rb +0 -394
  161. data/spec/auth/authenticator_spec.rb +0 -112
  162. data/spec/auth/password_reset_spec.rb +0 -294
  163. data/spec/auth/permission_checker_spec.rb +0 -207
  164. data/spec/auth/permission_spec.rb +0 -73
  165. data/spec/auth/rbac_adapter_spec.rb +0 -778
  166. data/spec/auth/rbac_config_spec.rb +0 -82
  167. data/spec/auth/role_spec.rb +0 -51
  168. data/spec/auth/session_manager_spec.rb +0 -172
  169. data/spec/auth/session_spec.rb +0 -112
  170. data/spec/auth/user_spec.rb +0 -130
  171. data/spec/comprehensive_edge_cases_spec.rb +0 -1777
  172. data/spec/context_spec.rb +0 -127
  173. data/spec/decision_agent_spec.rb +0 -96
  174. data/spec/decision_spec.rb +0 -423
  175. data/spec/dmn/decision_graph_spec.rb +0 -282
  176. data/spec/dmn/decision_tree_spec.rb +0 -203
  177. data/spec/dmn/feel/errors_spec.rb +0 -18
  178. data/spec/dmn/feel/functions_spec.rb +0 -400
  179. data/spec/dmn/feel/simple_parser_spec.rb +0 -274
  180. data/spec/dmn/feel/types_spec.rb +0 -176
  181. data/spec/dmn/feel_parser_spec.rb +0 -489
  182. data/spec/dmn/hit_policy_spec.rb +0 -202
  183. data/spec/dmn/integration_spec.rb +0 -226
  184. data/spec/dsl/condition_evaluator_spec.rb +0 -774
  185. data/spec/dsl_validation_spec.rb +0 -648
  186. data/spec/edge_cases_spec.rb +0 -353
  187. data/spec/evaluation_spec.rb +0 -364
  188. data/spec/evaluation_validator_spec.rb +0 -165
  189. data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
  190. data/spec/examples.txt +0 -1909
  191. data/spec/fixtures/dmn/complex_decision.dmn +0 -81
  192. data/spec/fixtures/dmn/invalid_structure.dmn +0 -31
  193. data/spec/fixtures/dmn/simple_decision.dmn +0 -40
  194. data/spec/issue_verification_spec.rb +0 -759
  195. data/spec/json_rule_evaluator_spec.rb +0 -587
  196. data/spec/monitoring/alert_manager_spec.rb +0 -378
  197. data/spec/monitoring/metrics_collector_spec.rb +0 -501
  198. data/spec/monitoring/monitored_agent_spec.rb +0 -225
  199. data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
  200. data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
  201. data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
  202. data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
  203. data/spec/performance_optimizations_spec.rb +0 -493
  204. data/spec/replay_edge_cases_spec.rb +0 -699
  205. data/spec/replay_spec.rb +0 -210
  206. data/spec/rfc8785_canonicalization_spec.rb +0 -215
  207. data/spec/scoring_spec.rb +0 -225
  208. data/spec/spec_helper.rb +0 -60
  209. data/spec/testing/batch_test_importer_spec.rb +0 -693
  210. data/spec/testing/batch_test_runner_spec.rb +0 -307
  211. data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
  212. data/spec/testing/test_result_comparator_spec.rb +0 -392
  213. data/spec/testing/test_scenario_spec.rb +0 -113
  214. data/spec/thread_safety_spec.rb +0 -490
  215. data/spec/thread_safety_spec.rb.broken +0 -878
  216. data/spec/versioning/adapter_spec.rb +0 -156
  217. data/spec/versioning_spec.rb +0 -1030
  218. data/spec/web/middleware/auth_middleware_spec.rb +0 -133
  219. data/spec/web/middleware/permission_middleware_spec.rb +0 -247
  220. data/spec/web_ui_rack_spec.rb +0 -2134
@@ -1,553 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "spec_helper"
4
-
5
- # Only run these tests if ActiveRecord is available
6
- if defined?(ActiveRecord)
7
- RSpec.describe "ActiveRecordAdapter Thread-Safety" do
8
- # Setup in-memory SQLite database for testing
9
- before(:all) do
10
- ActiveRecord::Base.establish_connection(
11
- adapter: "sqlite3",
12
- database: "file::memory:?cache=shared",
13
- timeout: 10_000,
14
- pool: 110 # Support 100 thread test + some overhead
15
- )
16
-
17
- # Enable WAL mode and busy timeout for better concurrency
18
- ActiveRecord::Base.connection.execute("PRAGMA journal_mode=WAL")
19
- ActiveRecord::Base.connection.execute("PRAGMA busy_timeout=10000")
20
-
21
- # Create the schema
22
- ActiveRecord::Schema.define do
23
- create_table :rule_versions, force: true do |t|
24
- t.string :rule_id, null: false
25
- t.integer :version_number, null: false
26
- t.text :content, null: false
27
- t.string :created_by, null: false, default: "system"
28
- t.text :changelog
29
- t.string :status, null: false, default: "draft"
30
- t.timestamps
31
- end
32
-
33
- add_index :rule_versions, %i[rule_id version_number], unique: true
34
- add_index :rule_versions, %i[rule_id status]
35
- end
36
-
37
- # Define RuleVersion model if not already defined
38
- unless defined?(RuleVersion)
39
- class ::RuleVersion < ActiveRecord::Base
40
- validates :rule_id, presence: true
41
- validates :version_number, presence: true, uniqueness: { scope: :rule_id }
42
- validates :content, presence: true
43
- validates :status, inclusion: { in: %w[draft active archived] }
44
- validates :created_by, presence: true
45
-
46
- scope :active, -> { where(status: "active") }
47
- scope :for_rule, ->(rule_id) { where(rule_id: rule_id).order(version_number: :desc) }
48
- scope :latest, -> { order(version_number: :desc).limit(1) }
49
-
50
- before_create :set_next_version_number
51
-
52
- def parsed_content
53
- JSON.parse(content, symbolize_names: true)
54
- rescue JSON::ParserError
55
- {}
56
- end
57
-
58
- def content_hash=(hash)
59
- self.content = hash.to_json
60
- end
61
-
62
- def activate!
63
- transaction do
64
- self.class.where(rule_id: rule_id, status: "active")
65
- .where.not(id: id)
66
- .update_all(status: "archived")
67
- update!(status: "active")
68
- end
69
- end
70
-
71
- private
72
-
73
- def set_next_version_number
74
- return if version_number.present?
75
-
76
- # Use pessimistic locking to prevent race conditions
77
- last_version = self.class.where(rule_id: rule_id)
78
- .order(version_number: :desc)
79
- .lock
80
- .first
81
-
82
- self.version_number = last_version ? last_version.version_number + 1 : 1
83
- end
84
- end
85
- end
86
- end
87
-
88
- before(:each) do
89
- RuleVersion.delete_all
90
- end
91
-
92
- let(:adapter) { DecisionAgent::Versioning::ActiveRecordAdapter.new }
93
- let(:rule_id) { "concurrent_test_rule" }
94
- let(:rule_content) do
95
- {
96
- version: "1.0",
97
- ruleset: "test_rules",
98
- rules: [
99
- {
100
- id: "test_rule",
101
- if: { field: "amount", op: "gt", value: 100 },
102
- then: { decision: "approve", weight: 0.8, reason: "Test" }
103
- }
104
- ]
105
- }
106
- end
107
-
108
- # Helper method to retry on SQLite busy exceptions (concurrency limitation)
109
- def with_retry(max_retries: 3, &block)
110
- retries = 0
111
- begin
112
- block.call
113
- rescue ActiveRecord::StatementInvalid => e
114
- raise unless e.message.include?("database is locked") && retries < max_retries
115
-
116
- retries += 1
117
- sleep(0.01 * retries) # Exponential backoff
118
- retry
119
- end
120
- end
121
-
122
- describe "concurrent version creation" do
123
- it "prevents duplicate version numbers with pessimistic locking" do
124
- thread_count = 20
125
- threads = []
126
- results = []
127
- mutex = Mutex.new
128
-
129
- # Spawn multiple threads creating versions concurrently
130
- thread_count.times do |i|
131
- threads << Thread.new do
132
- version = with_retry do
133
- adapter.create_version(
134
- rule_id: rule_id,
135
- content: rule_content.merge(thread_id: i),
136
- metadata: { created_by: "thread_#{i}" }
137
- )
138
- end
139
- mutex.synchronize { results << version }
140
- end
141
- end
142
-
143
- threads.each(&:join)
144
-
145
- # All versions should be created successfully
146
- expect(results.size).to eq(thread_count)
147
-
148
- # Version numbers must be unique and sequential
149
- version_numbers = results.map { |v| v[:version_number] }.sort
150
- expect(version_numbers).to eq((1..thread_count).to_a)
151
-
152
- # Verify in database
153
- db_versions = RuleVersion.where(rule_id: rule_id).order(:version_number)
154
- expect(db_versions.count).to eq(thread_count)
155
- expect(db_versions.pluck(:version_number)).to eq((1..thread_count).to_a)
156
- end
157
-
158
- it "handles high concurrency (100 threads)" do
159
- thread_count = 100
160
- threads = []
161
- errors = []
162
- mutex = Mutex.new
163
-
164
- thread_count.times do |i|
165
- threads << Thread.new do
166
- with_retry(max_retries: 10) do # Increased retry count for high concurrency
167
- adapter.create_version(
168
- rule_id: rule_id,
169
- content: rule_content,
170
- metadata: { created_by: "thread_#{i}" }
171
- )
172
- end
173
- rescue StandardError => e
174
- mutex.synchronize { errors << e }
175
- end
176
- end
177
-
178
- threads.each(&:join)
179
-
180
- # Should have no errors
181
- expect(errors).to be_empty
182
-
183
- # All versions created with unique version numbers
184
- versions = RuleVersion.where(rule_id: rule_id).order(:version_number)
185
- expect(versions.count).to eq(thread_count)
186
- expect(versions.pluck(:version_number)).to eq((1..thread_count).to_a)
187
- end
188
-
189
- it "maintains unique constraint even under extreme concurrency" do
190
- # This test verifies the database-level unique constraint catches any edge cases
191
- thread_count = 50
192
- threads = []
193
- successes = []
194
- failures = []
195
- mutex = Mutex.new
196
-
197
- thread_count.times do |i|
198
- threads << Thread.new do
199
- version = with_retry do
200
- adapter.create_version(
201
- rule_id: rule_id,
202
- content: rule_content,
203
- metadata: { created_by: "thread_#{i}" }
204
- )
205
- end
206
- mutex.synchronize { successes << version }
207
- rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
208
- # These errors are acceptable - they mean the unique constraint caught duplicates
209
- mutex.synchronize { failures << e }
210
- end
211
- end
212
-
213
- threads.each(&:join)
214
-
215
- # Either all succeed with unique versions, or some fail due to constraint
216
- total_attempts = successes.size + failures.size
217
- expect(total_attempts).to eq(thread_count)
218
-
219
- # All successful versions must have unique version numbers
220
- version_numbers = successes.map { |v| v[:version_number] }
221
- expect(version_numbers.uniq.size).to eq(version_numbers.size)
222
-
223
- # Database should have only unique versions
224
- db_versions = RuleVersion.where(rule_id: rule_id)
225
- db_version_numbers = db_versions.pluck(:version_number).sort
226
- expect(db_version_numbers).to eq(db_version_numbers.uniq)
227
- end
228
- end
229
-
230
- describe "concurrent read and write operations" do
231
- it "allows safe concurrent reads during writes" do
232
- # Create initial version
233
- adapter.create_version(
234
- rule_id: rule_id,
235
- content: rule_content,
236
- metadata: { created_by: "setup" }
237
- )
238
-
239
- threads = []
240
- read_results = []
241
- write_results = []
242
- read_mutex = Mutex.new
243
- write_mutex = Mutex.new
244
-
245
- # Mix of readers and writers
246
- 20.times do |i|
247
- threads << if i % 3 == 0
248
- # Write thread
249
- Thread.new do
250
- version = with_retry do
251
- adapter.create_version(
252
- rule_id: rule_id,
253
- content: rule_content,
254
- metadata: { created_by: "writer_#{i}" }
255
- )
256
- end
257
- write_mutex.synchronize { write_results << version }
258
- end
259
- else
260
- # Read thread
261
- Thread.new do
262
- versions = adapter.list_versions(rule_id: rule_id)
263
- read_mutex.synchronize { read_results << versions }
264
- end
265
- end
266
- end
267
-
268
- threads.each(&:join)
269
-
270
- # Readers should never see corrupted data
271
- read_results.each do |versions|
272
- expect(versions).to be_an(Array)
273
- versions.each do |v|
274
- expect(v[:version_number]).to be > 0
275
- expect(v[:rule_id]).to eq(rule_id)
276
- end
277
- end
278
-
279
- # Writers should create valid sequential versions
280
- write_version_numbers = write_results.map { |v| v[:version_number] }.sort
281
- expect(write_version_numbers.first).to eq(2) # First write creates version 2
282
- end
283
- end
284
-
285
- describe "status updates during concurrent creation" do
286
- it "ensures only one active version at a time" do
287
- thread_count = 10
288
- threads = []
289
-
290
- thread_count.times do |i|
291
- threads << Thread.new do
292
- with_retry do
293
- adapter.create_version(
294
- rule_id: rule_id,
295
- content: rule_content,
296
- metadata: { created_by: "thread_#{i}", status: "active" }
297
- )
298
- end
299
- end
300
- end
301
-
302
- threads.each(&:join)
303
-
304
- # Only the last created version should be active
305
- active_versions = RuleVersion.where(rule_id: rule_id, status: "active")
306
- expect(active_versions.count).to eq(1)
307
-
308
- # The active version should be the last one
309
- expect(active_versions.first.version_number).to eq(thread_count)
310
-
311
- # All others should be archived
312
- archived_versions = RuleVersion.where(rule_id: rule_id, status: "archived")
313
- expect(archived_versions.count).to eq(thread_count - 1)
314
- end
315
- end
316
-
317
- describe "multiple rules concurrently" do
318
- it "handles version creation for different rules in parallel" do
319
- rule_ids = (1..10).map { |i| "rule_#{i}" }
320
- threads = []
321
- results = {}
322
- mutex = Mutex.new
323
-
324
- rule_ids.each do |rid|
325
- 5.times do |version_index|
326
- threads << Thread.new do
327
- version = with_retry do
328
- adapter.create_version(
329
- rule_id: rid,
330
- content: rule_content,
331
- metadata: { created_by: "creator_#{version_index}" }
332
- )
333
- end
334
- mutex.synchronize do
335
- results[rid] ||= []
336
- results[rid] << version
337
- end
338
- end
339
- end
340
- end
341
-
342
- threads.each(&:join)
343
-
344
- # Each rule should have 5 versions
345
- rule_ids.each do |rid|
346
- expect(results[rid].size).to eq(5)
347
- version_numbers = results[rid].map { |v| v[:version_number] }.sort
348
- expect(version_numbers).to eq([1, 2, 3, 4, 5])
349
- end
350
- end
351
- end
352
-
353
- describe "transaction rollback on errors" do
354
- it "rolls back version creation if there's an error" do
355
- # Create a scenario where create might fail
356
- allow(RuleVersion).to receive(:create!).and_raise(ActiveRecord::RecordInvalid.new(RuleVersion.new))
357
-
358
- expect do
359
- adapter.create_version(
360
- rule_id: rule_id,
361
- content: rule_content
362
- )
363
- end.to raise_error(ActiveRecord::RecordInvalid)
364
-
365
- # No versions should be created
366
- expect(RuleVersion.where(rule_id: rule_id).count).to eq(0)
367
- end
368
- end
369
-
370
- describe "RuleVersion model callback thread safety" do
371
- it "safely calculates version numbers when using model directly" do
372
- thread_count = 30
373
- threads = []
374
- errors = []
375
- mutex = Mutex.new
376
-
377
- thread_count.times do |i|
378
- threads << Thread.new do
379
- RuleVersion.create!(
380
- rule_id: rule_id,
381
- content: rule_content.to_json,
382
- created_by: "thread_#{i}",
383
- status: "draft"
384
- )
385
- rescue StandardError => e
386
- mutex.synchronize { errors << e }
387
- end
388
- end
389
-
390
- threads.each(&:join)
391
-
392
- # Should have minimal or no errors (unique constraint might catch some)
393
- # The key is version numbers should be unique
394
- versions = RuleVersion.where(rule_id: rule_id).order(:version_number)
395
- version_numbers = versions.pluck(:version_number)
396
-
397
- # All version numbers should be unique
398
- expect(version_numbers.uniq.size).to eq(version_numbers.size)
399
- end
400
- end
401
-
402
- describe "concurrent activate_version" do
403
- it "prevents multiple active versions with pessimistic locking" do
404
- # Create 5 versions
405
- versions = 5.times.map do |i|
406
- with_retry do
407
- adapter.create_version(
408
- rule_id: rule_id,
409
- content: rule_content.merge(version: "#{i + 1}.0"),
410
- metadata: { created_by: "setup_#{i}" }
411
- )
412
- end
413
- end
414
-
415
- # Try to activate different versions concurrently
416
- thread_count = 10
417
- threads = []
418
- activated_versions = []
419
- mutex = Mutex.new
420
-
421
- thread_count.times do |i|
422
- threads << Thread.new do
423
- # Each thread tries to activate a different version (cycling through versions)
424
- version_to_activate = versions[i % versions.size]
425
- activated = with_retry do
426
- adapter.activate_version(version_id: version_to_activate[:id])
427
- end
428
- mutex.synchronize { activated_versions << activated }
429
- end
430
- end
431
-
432
- threads.each(&:join)
433
-
434
- # CRITICAL: Only ONE version should be active at the end
435
- active_versions = RuleVersion.where(rule_id: rule_id, status: "active")
436
- expect(active_versions.count).to eq(1),
437
- "Expected exactly 1 active version, but found #{active_versions.count}: #{active_versions.pluck(:version_number)}"
438
-
439
- # All other versions should be archived
440
- archived_versions = RuleVersion.where(rule_id: rule_id, status: "archived")
441
- expect(archived_versions.count).to eq(versions.size - 1)
442
-
443
- # The active version should be one of the versions we tried to activate
444
- active_version_id = active_versions.first.id
445
- expect(versions.map { |v| v[:id] }).to include(active_version_id)
446
- end
447
-
448
- it "handles race condition when two threads activate different versions simultaneously" do
449
- # Create 3 versions
450
- v1 = with_retry do
451
- adapter.create_version(
452
- rule_id: rule_id,
453
- content: rule_content.merge(version: "1.0"),
454
- metadata: { created_by: "setup" }
455
- )
456
- end
457
- v2 = with_retry do
458
- adapter.create_version(
459
- rule_id: rule_id,
460
- content: rule_content.merge(version: "2.0"),
461
- metadata: { created_by: "setup" }
462
- )
463
- end
464
- with_retry do
465
- adapter.create_version(
466
- rule_id: rule_id,
467
- content: rule_content.merge(version: "3.0"),
468
- metadata: { created_by: "setup" }
469
- )
470
- end
471
-
472
- # At this point v3 is active, v1 and v2 are archived
473
-
474
- # Spawn two threads trying to activate v1 and v2 at the same time
475
- begin
476
- barrier = begin
477
- Concurrent::CyclicBarrier.new(2)
478
- rescue StandardError
479
- Thread::Barrier.new(2)
480
- end
481
- rescue StandardError
482
- nil
483
- end
484
- threads = []
485
-
486
- if barrier
487
- threads << Thread.new do
488
- barrier.wait
489
- with_retry { adapter.activate_version(version_id: v1[:id]) }
490
- end
491
-
492
- threads << Thread.new do
493
- barrier.wait
494
- with_retry { adapter.activate_version(version_id: v2[:id]) }
495
- end
496
-
497
- threads.each(&:join)
498
- else
499
- # Fallback without barrier - still tests thread safety
500
- t1 = Thread.new { with_retry { adapter.activate_version(version_id: v1[:id]) } }
501
- t2 = Thread.new { with_retry { adapter.activate_version(version_id: v2[:id]) } }
502
- t1.join
503
- t2.join
504
- end
505
-
506
- # CRITICAL: Only ONE version should be active
507
- active_count = RuleVersion.where(rule_id: rule_id, status: "active").count
508
- expect(active_count).to eq(1),
509
- "Race condition detected: #{active_count} active versions found instead of 1"
510
- end
511
-
512
- it "maintains consistency across 100 concurrent activation attempts" do
513
- # Create 10 versions
514
- versions = 10.times.map do |i|
515
- with_retry do
516
- adapter.create_version(
517
- rule_id: rule_id,
518
- content: rule_content.merge(version: "#{i + 1}.0"),
519
- metadata: { created_by: "setup_#{i}" }
520
- )
521
- end
522
- end
523
-
524
- # 100 threads each randomly activating versions
525
- threads = 100.times.map do
526
- Thread.new do
527
- random_version = versions.sample
528
- with_retry do
529
- adapter.activate_version(version_id: random_version[:id])
530
- end
531
- sleep(rand * 0.01) # Small random delay to increase race condition likelihood
532
- end
533
- end
534
-
535
- threads.each(&:join)
536
-
537
- # Check consistency
538
- active_versions = RuleVersion.where(rule_id: rule_id, status: "active")
539
- expect(active_versions.count).to eq(1),
540
- "Consistency violation: #{active_versions.count} active versions after concurrent activations"
541
-
542
- # All versions should still exist
543
- expect(RuleVersion.where(rule_id: rule_id).count).to eq(10)
544
- end
545
- end
546
- end
547
- else
548
- RSpec.describe "ActiveRecordAdapter Thread-Safety" do
549
- it "skips tests when ActiveRecord is not available" do
550
- skip "ActiveRecord is not loaded"
551
- end
552
- end
553
- end