decision_agent 0.3.0 → 1.0.1

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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +272 -7
  3. data/lib/decision_agent/agent.rb +72 -1
  4. data/lib/decision_agent/context.rb +1 -0
  5. data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +86 -0
  6. data/lib/decision_agent/data_enrichment/cache_adapter.rb +49 -0
  7. data/lib/decision_agent/data_enrichment/circuit_breaker.rb +135 -0
  8. data/lib/decision_agent/data_enrichment/client.rb +220 -0
  9. data/lib/decision_agent/data_enrichment/config.rb +78 -0
  10. data/lib/decision_agent/data_enrichment/errors.rb +36 -0
  11. data/lib/decision_agent/decision.rb +102 -2
  12. data/lib/decision_agent/dmn/feel/evaluator.rb +28 -6
  13. data/lib/decision_agent/dsl/condition_evaluator.rb +982 -839
  14. data/lib/decision_agent/dsl/schema_validator.rb +51 -13
  15. data/lib/decision_agent/evaluators/dmn_evaluator.rb +106 -19
  16. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +69 -9
  17. data/lib/decision_agent/explainability/condition_trace.rb +83 -0
  18. data/lib/decision_agent/explainability/explainability_result.rb +52 -0
  19. data/lib/decision_agent/explainability/rule_trace.rb +39 -0
  20. data/lib/decision_agent/explainability/trace_collector.rb +24 -0
  21. data/lib/decision_agent/monitoring/alert_manager.rb +5 -1
  22. data/lib/decision_agent/simulation/errors.rb +18 -0
  23. data/lib/decision_agent/simulation/impact_analyzer.rb +498 -0
  24. data/lib/decision_agent/simulation/monte_carlo_simulator.rb +635 -0
  25. data/lib/decision_agent/simulation/replay_engine.rb +486 -0
  26. data/lib/decision_agent/simulation/scenario_engine.rb +318 -0
  27. data/lib/decision_agent/simulation/scenario_library.rb +163 -0
  28. data/lib/decision_agent/simulation/shadow_test_engine.rb +287 -0
  29. data/lib/decision_agent/simulation/what_if_analyzer.rb +1002 -0
  30. data/lib/decision_agent/simulation.rb +17 -0
  31. data/lib/decision_agent/version.rb +1 -1
  32. data/lib/decision_agent/versioning/activerecord_adapter.rb +23 -8
  33. data/lib/decision_agent/web/public/app.js +119 -0
  34. data/lib/decision_agent/web/public/index.html +49 -0
  35. data/lib/decision_agent/web/public/simulation.html +130 -0
  36. data/lib/decision_agent/web/public/simulation_impact.html +478 -0
  37. data/lib/decision_agent/web/public/simulation_replay.html +551 -0
  38. data/lib/decision_agent/web/public/simulation_shadow.html +546 -0
  39. data/lib/decision_agent/web/public/simulation_whatif.html +532 -0
  40. data/lib/decision_agent/web/public/styles.css +65 -0
  41. data/lib/decision_agent/web/server.rb +594 -23
  42. data/lib/decision_agent.rb +60 -2
  43. metadata +53 -73
  44. data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
  45. data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
  46. data/spec/ab_testing/ab_test_spec.rb +0 -270
  47. data/spec/ab_testing/ab_testing_agent_spec.rb +0 -655
  48. data/spec/ab_testing/storage/adapter_spec.rb +0 -64
  49. data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
  50. data/spec/activerecord_thread_safety_spec.rb +0 -553
  51. data/spec/advanced_operators_spec.rb +0 -3150
  52. data/spec/agent_spec.rb +0 -289
  53. data/spec/api_contract_spec.rb +0 -430
  54. data/spec/audit_adapters_spec.rb +0 -92
  55. data/spec/auth/access_audit_logger_spec.rb +0 -394
  56. data/spec/auth/authenticator_spec.rb +0 -112
  57. data/spec/auth/password_reset_spec.rb +0 -294
  58. data/spec/auth/permission_checker_spec.rb +0 -207
  59. data/spec/auth/permission_spec.rb +0 -73
  60. data/spec/auth/rbac_adapter_spec.rb +0 -778
  61. data/spec/auth/rbac_config_spec.rb +0 -82
  62. data/spec/auth/role_spec.rb +0 -51
  63. data/spec/auth/session_manager_spec.rb +0 -172
  64. data/spec/auth/session_spec.rb +0 -112
  65. data/spec/auth/user_spec.rb +0 -130
  66. data/spec/comprehensive_edge_cases_spec.rb +0 -1777
  67. data/spec/context_spec.rb +0 -127
  68. data/spec/decision_agent_spec.rb +0 -96
  69. data/spec/decision_spec.rb +0 -423
  70. data/spec/dmn/decision_graph_spec.rb +0 -282
  71. data/spec/dmn/decision_tree_spec.rb +0 -203
  72. data/spec/dmn/feel/errors_spec.rb +0 -18
  73. data/spec/dmn/feel/functions_spec.rb +0 -400
  74. data/spec/dmn/feel/simple_parser_spec.rb +0 -274
  75. data/spec/dmn/feel/types_spec.rb +0 -176
  76. data/spec/dmn/feel_parser_spec.rb +0 -489
  77. data/spec/dmn/hit_policy_spec.rb +0 -202
  78. data/spec/dmn/integration_spec.rb +0 -226
  79. data/spec/dsl/condition_evaluator_spec.rb +0 -774
  80. data/spec/dsl_validation_spec.rb +0 -648
  81. data/spec/edge_cases_spec.rb +0 -353
  82. data/spec/evaluation_spec.rb +0 -364
  83. data/spec/evaluation_validator_spec.rb +0 -165
  84. data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
  85. data/spec/examples.txt +0 -1909
  86. data/spec/fixtures/dmn/complex_decision.dmn +0 -81
  87. data/spec/fixtures/dmn/invalid_structure.dmn +0 -31
  88. data/spec/fixtures/dmn/simple_decision.dmn +0 -40
  89. data/spec/issue_verification_spec.rb +0 -759
  90. data/spec/json_rule_evaluator_spec.rb +0 -587
  91. data/spec/monitoring/alert_manager_spec.rb +0 -378
  92. data/spec/monitoring/metrics_collector_spec.rb +0 -501
  93. data/spec/monitoring/monitored_agent_spec.rb +0 -225
  94. data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
  95. data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
  96. data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
  97. data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
  98. data/spec/performance_optimizations_spec.rb +0 -493
  99. data/spec/replay_edge_cases_spec.rb +0 -699
  100. data/spec/replay_spec.rb +0 -210
  101. data/spec/rfc8785_canonicalization_spec.rb +0 -215
  102. data/spec/scoring_spec.rb +0 -225
  103. data/spec/spec_helper.rb +0 -60
  104. data/spec/testing/batch_test_importer_spec.rb +0 -693
  105. data/spec/testing/batch_test_runner_spec.rb +0 -307
  106. data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
  107. data/spec/testing/test_result_comparator_spec.rb +0 -392
  108. data/spec/testing/test_scenario_spec.rb +0 -113
  109. data/spec/thread_safety_spec.rb +0 -490
  110. data/spec/thread_safety_spec.rb.broken +0 -878
  111. data/spec/versioning/adapter_spec.rb +0 -156
  112. data/spec/versioning_spec.rb +0 -1030
  113. data/spec/web/middleware/auth_middleware_spec.rb +0 -133
  114. data/spec/web/middleware/permission_middleware_spec.rb +0 -247
  115. 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