decision_agent 0.1.2 → 0.1.3
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/README.md +212 -35
- data/bin/decision_agent +3 -8
- data/lib/decision_agent/agent.rb +19 -26
- data/lib/decision_agent/audit/null_adapter.rb +1 -2
- data/lib/decision_agent/decision.rb +3 -1
- data/lib/decision_agent/dsl/condition_evaluator.rb +4 -3
- data/lib/decision_agent/dsl/rule_parser.rb +4 -6
- data/lib/decision_agent/dsl/schema_validator.rb +27 -31
- data/lib/decision_agent/errors.rb +11 -8
- data/lib/decision_agent/evaluation.rb +3 -1
- data/lib/decision_agent/evaluation_validator.rb +78 -0
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +26 -0
- data/lib/decision_agent/evaluators/static_evaluator.rb +2 -6
- data/lib/decision_agent/monitoring/alert_manager.rb +282 -0
- data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +381 -0
- data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +471 -0
- data/lib/decision_agent/monitoring/dashboard/public/index.html +161 -0
- data/lib/decision_agent/monitoring/dashboard_server.rb +340 -0
- data/lib/decision_agent/monitoring/metrics_collector.rb +278 -0
- data/lib/decision_agent/monitoring/monitored_agent.rb +71 -0
- data/lib/decision_agent/monitoring/prometheus_exporter.rb +247 -0
- data/lib/decision_agent/replay/replay.rb +12 -22
- data/lib/decision_agent/scoring/base.rb +1 -1
- data/lib/decision_agent/scoring/consensus.rb +5 -5
- data/lib/decision_agent/scoring/weighted_average.rb +1 -1
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +69 -33
- data/lib/decision_agent/versioning/adapter.rb +1 -3
- data/lib/decision_agent/versioning/file_storage_adapter.rb +143 -35
- data/lib/decision_agent/versioning/version_manager.rb +4 -12
- data/lib/decision_agent/web/public/index.html +1 -1
- data/lib/decision_agent/web/server.rb +19 -24
- data/lib/decision_agent.rb +7 -0
- data/lib/generators/decision_agent/install/install_generator.rb +5 -5
- data/lib/generators/decision_agent/install/templates/migration.rb +17 -6
- data/lib/generators/decision_agent/install/templates/rule.rb +3 -3
- data/lib/generators/decision_agent/install/templates/rule_version.rb +13 -7
- data/spec/activerecord_thread_safety_spec.rb +553 -0
- data/spec/agent_spec.rb +13 -13
- data/spec/api_contract_spec.rb +16 -16
- data/spec/audit_adapters_spec.rb +3 -3
- data/spec/comprehensive_edge_cases_spec.rb +86 -86
- data/spec/dsl_validation_spec.rb +83 -83
- data/spec/edge_cases_spec.rb +23 -23
- data/spec/examples/feedback_aware_evaluator_spec.rb +7 -7
- data/spec/examples.txt +548 -0
- data/spec/issue_verification_spec.rb +685 -0
- data/spec/json_rule_evaluator_spec.rb +15 -15
- data/spec/monitoring/alert_manager_spec.rb +378 -0
- data/spec/monitoring/metrics_collector_spec.rb +281 -0
- data/spec/monitoring/monitored_agent_spec.rb +222 -0
- data/spec/monitoring/prometheus_exporter_spec.rb +242 -0
- data/spec/replay_edge_cases_spec.rb +58 -58
- data/spec/replay_spec.rb +11 -11
- data/spec/rfc8785_canonicalization_spec.rb +215 -0
- data/spec/scoring_spec.rb +1 -1
- data/spec/spec_helper.rb +9 -0
- data/spec/thread_safety_spec.rb +482 -0
- data/spec/thread_safety_spec.rb.broken +878 -0
- data/spec/versioning_spec.rb +141 -37
- data/spec/web_ui_rack_spec.rb +135 -0
- metadata +69 -6
|
@@ -0,0 +1,553 @@
|
|
|
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
|
data/spec/agent_spec.rb
CHANGED
|
@@ -3,41 +3,41 @@ require "spec_helper"
|
|
|
3
3
|
RSpec.describe DecisionAgent::Agent do
|
|
4
4
|
describe "#initialize" do
|
|
5
5
|
it "requires at least one evaluator" do
|
|
6
|
-
expect
|
|
6
|
+
expect do
|
|
7
7
|
DecisionAgent::Agent.new(evaluators: [])
|
|
8
|
-
|
|
8
|
+
end.to raise_error(DecisionAgent::InvalidConfigurationError, /at least one evaluator/i)
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
it "validates evaluators respond to #evaluate" do
|
|
12
12
|
invalid_evaluator = Object.new
|
|
13
13
|
|
|
14
|
-
expect
|
|
14
|
+
expect do
|
|
15
15
|
DecisionAgent::Agent.new(evaluators: [invalid_evaluator])
|
|
16
|
-
|
|
16
|
+
end.to raise_error(DecisionAgent::InvalidEvaluatorError)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
it "validates scoring strategy responds to #score" do
|
|
20
20
|
evaluator = DecisionAgent::Evaluators::StaticEvaluator.new(decision: "approve")
|
|
21
21
|
invalid_strategy = Object.new
|
|
22
22
|
|
|
23
|
-
expect
|
|
23
|
+
expect do
|
|
24
24
|
DecisionAgent::Agent.new(
|
|
25
25
|
evaluators: [evaluator],
|
|
26
26
|
scoring_strategy: invalid_strategy
|
|
27
27
|
)
|
|
28
|
-
|
|
28
|
+
end.to raise_error(DecisionAgent::InvalidScoringStrategyError)
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
it "validates audit adapter responds to #record" do
|
|
32
32
|
evaluator = DecisionAgent::Evaluators::StaticEvaluator.new(decision: "approve")
|
|
33
33
|
invalid_adapter = Object.new
|
|
34
34
|
|
|
35
|
-
expect
|
|
35
|
+
expect do
|
|
36
36
|
DecisionAgent::Agent.new(
|
|
37
37
|
evaluators: [evaluator],
|
|
38
38
|
audit_adapter: invalid_adapter
|
|
39
39
|
)
|
|
40
|
-
|
|
40
|
+
end.to raise_error(DecisionAgent::InvalidAuditAdapterError)
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
it "uses defaults when optional parameters are omitted" do
|
|
@@ -82,21 +82,21 @@ RSpec.describe DecisionAgent::Agent do
|
|
|
82
82
|
|
|
83
83
|
it "raises NoEvaluationsError when no evaluators return decisions" do
|
|
84
84
|
failing_evaluator = Class.new(DecisionAgent::Evaluators::Base) do
|
|
85
|
-
def evaluate(
|
|
85
|
+
def evaluate(_context, feedback: {})
|
|
86
86
|
nil
|
|
87
87
|
end
|
|
88
88
|
end
|
|
89
89
|
|
|
90
90
|
agent = DecisionAgent::Agent.new(evaluators: [failing_evaluator.new])
|
|
91
91
|
|
|
92
|
-
expect
|
|
92
|
+
expect do
|
|
93
93
|
agent.decide(context: {})
|
|
94
|
-
|
|
94
|
+
end.to raise_error(DecisionAgent::NoEvaluationsError)
|
|
95
95
|
end
|
|
96
96
|
|
|
97
97
|
it "includes feedback in evaluation" do
|
|
98
98
|
feedback_evaluator = Class.new(DecisionAgent::Evaluators::Base) do
|
|
99
|
-
def evaluate(
|
|
99
|
+
def evaluate(_context, feedback: {})
|
|
100
100
|
decision = feedback[:override] ? "reject" : "approve"
|
|
101
101
|
DecisionAgent::Evaluation.new(
|
|
102
102
|
decision: decision,
|
|
@@ -234,7 +234,7 @@ RSpec.describe DecisionAgent::Agent do
|
|
|
234
234
|
good_evaluator = DecisionAgent::Evaluators::StaticEvaluator.new(decision: "approve")
|
|
235
235
|
|
|
236
236
|
bad_evaluator = Class.new(DecisionAgent::Evaluators::Base) do
|
|
237
|
-
def evaluate(
|
|
237
|
+
def evaluate(_context, feedback: {})
|
|
238
238
|
raise StandardError, "Intentional error"
|
|
239
239
|
end
|
|
240
240
|
end
|