inferno_core 1.4.1 → 1.4.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/lib/inferno/apps/cli/execute_script.rb +27 -5
- data/lib/inferno/apps/cli/requirements_coverage_checker.rb +1 -1
- data/lib/inferno/apps/cli/session/session_compare.rb +27 -3
- data/lib/inferno/apps/cli/session/session_status.rb +20 -4
- data/lib/inferno/apps/web/controllers/test_runs/create.rb +8 -1
- data/lib/inferno/apps/web/controllers/test_runs/destroy.rb +10 -1
- data/lib/inferno/config/boot/db.rb +45 -0
- data/lib/inferno/config/boot/executor.rb +4 -0
- data/lib/inferno/config/boot/web.rb +4 -0
- data/lib/inferno/dsl/fhir_resource_navigation.rb +12 -2
- data/lib/inferno/dsl/fhir_resource_validation.rb +96 -0
- data/lib/inferno/dsl/input_output_handling.rb +7 -5
- data/lib/inferno/dsl/must_support_assessment.rb +19 -8
- data/lib/inferno/dsl/must_support_metadata_extractor.rb +44 -1
- data/lib/inferno/dsl/profile_metadata.rb +113 -0
- data/lib/inferno/dsl/resume_test_route.rb +14 -2
- data/lib/inferno/dsl/suite_endpoint.rb +176 -7
- data/lib/inferno/dsl.rb +1 -0
- data/lib/inferno/entities/ig.rb +51 -8
- data/lib/inferno/jobs.rb +5 -1
- data/lib/inferno/public/bundle.js +34 -34
- data/lib/inferno/public/bundle.js.LICENSE.txt +0 -2
- data/lib/inferno/test_runner.rb +61 -28
- data/lib/inferno/utils/db_concurrency_check.rb +166 -0
- data/lib/inferno/version.rb +1 -1
- metadata +4 -2
data/lib/inferno/test_runner.rb
CHANGED
|
@@ -59,28 +59,24 @@ module Inferno
|
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
def run_test(test, scratch)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
test_instance =
|
|
66
|
-
test.new(
|
|
67
|
-
inputs:,
|
|
68
|
-
test_session_id: test_session.id,
|
|
69
|
-
scratch:,
|
|
70
|
-
suite_options: test_session.suite_options_hash
|
|
71
|
-
)
|
|
62
|
+
around_test(test) do
|
|
63
|
+
inputs = load_inputs(test)
|
|
64
|
+
input_json_string = inputs_as_json(test, inputs)
|
|
72
65
|
|
|
73
|
-
|
|
66
|
+
test_instance =
|
|
67
|
+
test.new(
|
|
68
|
+
inputs:,
|
|
69
|
+
test_session_id: test_session.id,
|
|
70
|
+
scratch:,
|
|
71
|
+
suite_options: test_session.suite_options_hash
|
|
72
|
+
)
|
|
74
73
|
|
|
75
|
-
|
|
76
|
-
output_json_string = JSON.generate(outputs)
|
|
74
|
+
result = evaluate_runnable_result(test, test_instance, inputs)
|
|
77
75
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
end
|
|
76
|
+
outputs = save_outputs(test_instance)
|
|
77
|
+
output_json_string = JSON.generate(outputs)
|
|
81
78
|
|
|
82
|
-
|
|
83
|
-
{
|
|
79
|
+
result_params = {
|
|
84
80
|
messages: test_instance.messages,
|
|
85
81
|
requests: test_instance.requests,
|
|
86
82
|
result:,
|
|
@@ -88,15 +84,41 @@ module Inferno
|
|
|
88
84
|
input_json: input_json_string,
|
|
89
85
|
output_json: output_json_string
|
|
90
86
|
}.merge(test.reference_hash)
|
|
91
|
-
)
|
|
92
87
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
88
|
+
test_result =
|
|
89
|
+
if result == 'wait'
|
|
90
|
+
# The waiting status and the wait result must become visible to
|
|
91
|
+
# readers atomically, so that pollers never see the run waiting
|
|
92
|
+
# without the waiting test's result being present.
|
|
93
|
+
Inferno::Application['db.connection'].transaction do
|
|
94
|
+
test_runs_repo.mark_as_waiting(test_run.id, test_instance.identifier, test_instance.wait_timeout)
|
|
95
|
+
persist_result(result_params)
|
|
96
|
+
end
|
|
97
|
+
else
|
|
98
|
+
persist_result(result_params)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# If running a single test, update its parents' results. If running a
|
|
102
|
+
# group or suite, #run_group handles updating the parents.
|
|
103
|
+
update_parent_result(test.parent) if test_run.test_id.present?
|
|
104
|
+
|
|
105
|
+
test_result
|
|
106
|
+
end
|
|
107
|
+
end
|
|
98
108
|
|
|
99
|
-
|
|
109
|
+
# Wraps the execution of a single test. Prepend a module overriding this method
|
|
110
|
+
# to run instrumentation around each test, for example to emit a distinct trace,
|
|
111
|
+
# span, or timing metric per test instead of one that spans the whole run.
|
|
112
|
+
#
|
|
113
|
+
# An override must call `super` exactly once and return its value unchanged:
|
|
114
|
+
# it is the test's result, and parent results are rolled up from it. An
|
|
115
|
+
# override that does not call `super` silently skips the test.
|
|
116
|
+
#
|
|
117
|
+
# @param test [Class] the test being run (an Inferno::Entities::Test subclass)
|
|
118
|
+
# @yield executes the test and returns its result
|
|
119
|
+
# @return the value returned by the block
|
|
120
|
+
def around_test(_test)
|
|
121
|
+
yield
|
|
100
122
|
end
|
|
101
123
|
|
|
102
124
|
def check_inputs(test, _test_instance, inputs)
|
|
@@ -249,9 +271,20 @@ module Inferno
|
|
|
249
271
|
end
|
|
250
272
|
|
|
251
273
|
def persist_result(params)
|
|
252
|
-
result
|
|
253
|
-
|
|
254
|
-
|
|
274
|
+
# A single result can cascade into many separate inserts (messages,
|
|
275
|
+
# requests, headers, tags). Wrapping them in one transaction turns that
|
|
276
|
+
# into a single lock acquisition instead of one per insert, cutting
|
|
277
|
+
# down on sqlite write-lock contention with concurrent readers/writers.
|
|
278
|
+
#
|
|
279
|
+
# If you change this, run `bundle exec rake db:check_concurrency` to verify
|
|
280
|
+
# SQLITE_BUSY is still avoided under concurrent test-run writes and status
|
|
281
|
+
# polling (see lib/inferno/utils/db_concurrency_check.rb for why that's a
|
|
282
|
+
# rake task and not a spec).
|
|
283
|
+
result = Inferno::Application['db.connection'].transaction do
|
|
284
|
+
results_repo.create(
|
|
285
|
+
params.merge(test_run_id: test_run.id, test_session_id: test_session.id)
|
|
286
|
+
)
|
|
287
|
+
end
|
|
255
288
|
|
|
256
289
|
run_results[result.runnable.id] = result
|
|
257
290
|
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
require 'sequel'
|
|
2
|
+
require 'sqlite3'
|
|
3
|
+
require 'tmpdir'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
module Inferno
|
|
7
|
+
module Utils
|
|
8
|
+
# Verifies that the sqlite WAL/busy_timeout/transaction-wrapping in
|
|
9
|
+
# lib/inferno/config/boot/db.rb and TestRunner#persist_result actually
|
|
10
|
+
# prevent SQLITE_BUSY under concurrent test-run writes and status-polling
|
|
11
|
+
# reads against a shared sqlite file.
|
|
12
|
+
#
|
|
13
|
+
# This is deliberately NOT an rspec example. It's a real-time concurrency
|
|
14
|
+
# measurement, and a hardcoded load level that reliably reproduces (or
|
|
15
|
+
# avoids) SQLITE_BUSY on one machine does not transfer to others: a load
|
|
16
|
+
# level tuned to fail reliably without the fix and pass reliably with it,
|
|
17
|
+
# verified repeatedly on one machine, failed consistently in GitHub
|
|
18
|
+
# Actions and on another local machine. Rather than assert against a
|
|
19
|
+
# fixed load level, this calibrates to whatever machine it runs on: it
|
|
20
|
+
# escalates the write volume until it can demonstrate the *unprotected*
|
|
21
|
+
# configuration actually hits SQLITE_BUSY, then checks whether the
|
|
22
|
+
# *protected* configuration avoids it under that same, machine-calibrated
|
|
23
|
+
# load.
|
|
24
|
+
#
|
|
25
|
+
# Run manually with `bundle exec rake db:check_concurrency` after changing
|
|
26
|
+
# the persistence/locking behavior in the files above. It isn't part of
|
|
27
|
+
# the default test suite because it's a real-time measurement, not a
|
|
28
|
+
# deterministic assertion, and shouldn't gate CI.
|
|
29
|
+
module DbConcurrencyCheck
|
|
30
|
+
WRITER_THREADS = 5
|
|
31
|
+
READER_THREADS = 3
|
|
32
|
+
READER_POLL_INTERVAL = 0.2
|
|
33
|
+
MAX_CONNECTIONS = 15
|
|
34
|
+
MAX_CASCADES_PER_WRITER = 5000
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
def run
|
|
39
|
+
cascades = 10
|
|
40
|
+
baseline_errors = []
|
|
41
|
+
|
|
42
|
+
loop do
|
|
43
|
+
baseline_errors = measure(protected: false, cascades_per_writer: cascades)
|
|
44
|
+
break if baseline_errors.any? || cascades > MAX_CASCADES_PER_WRITER
|
|
45
|
+
|
|
46
|
+
cascades *= 2
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
if baseline_errors.empty?
|
|
50
|
+
puts "INCONCLUSIVE: could not reproduce SQLITE_BUSY even at #{cascades} cascades/writer on this machine."
|
|
51
|
+
return true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
puts "Reproduced #{baseline_errors.size} SQLITE_BUSY error(s) without WAL/busy_timeout/transaction-" \
|
|
55
|
+
"wrapping at #{cascades} cascades/writer (#{WRITER_THREADS} writers, #{READER_THREADS} readers " \
|
|
56
|
+
"polling every #{READER_POLL_INTERVAL}s)."
|
|
57
|
+
|
|
58
|
+
protected_errors = measure(protected: true, cascades_per_writer: cascades)
|
|
59
|
+
|
|
60
|
+
if protected_errors.empty?
|
|
61
|
+
puts 'PASS: the current fix avoided SQLITE_BUSY under the same load that reproduced it above.'
|
|
62
|
+
true
|
|
63
|
+
else
|
|
64
|
+
puts "FAIL: the current fix still hit #{protected_errors.size} SQLITE_BUSY error(s) under the same load:"
|
|
65
|
+
puts protected_errors.first.message
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def measure(protected:, cascades_per_writer:)
|
|
71
|
+
Dir.mktmpdir do |dir|
|
|
72
|
+
db = connect(File.join(dir, 'contention.db'), protected:)
|
|
73
|
+
result_class, request_class, header_class = build_schema_and_models(db)
|
|
74
|
+
errors = []
|
|
75
|
+
mutex = Mutex.new
|
|
76
|
+
stop_reading = false
|
|
77
|
+
|
|
78
|
+
writers = Array.new(WRITER_THREADS) do
|
|
79
|
+
Thread.new do
|
|
80
|
+
cascades_per_writer.times do
|
|
81
|
+
persist_cascade(db, result_class, request_class, header_class, protected:)
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
mutex.synchronize { errors << e }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
readers = Array.new(READER_THREADS) do
|
|
89
|
+
Thread.new { poll_until(-> { stop_reading }, result_class, request_class, errors, mutex) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
writers.each(&:join)
|
|
93
|
+
stop_reading = true
|
|
94
|
+
readers.each(&:join)
|
|
95
|
+
db.disconnect
|
|
96
|
+
|
|
97
|
+
errors.select { |e| e.is_a?(Sequel::DatabaseError) && e.message =~ /database is locked|SQLITE_BUSY/i }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def connect(db_path, protected:)
|
|
102
|
+
connect_sqls =
|
|
103
|
+
if protected
|
|
104
|
+
["PRAGMA busy_timeout = #{ENV.fetch('DB_BUSY_TIMEOUT_MS', '15000')}", 'PRAGMA journal_mode = WAL']
|
|
105
|
+
else
|
|
106
|
+
['PRAGMA busy_timeout = 0']
|
|
107
|
+
end
|
|
108
|
+
Sequel.connect(adapter: 'sqlite', database: db_path, max_connections: MAX_CONNECTIONS, connect_sqls:)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def persist_cascade(db, result_class, request_class, header_class, protected:)
|
|
112
|
+
if protected
|
|
113
|
+
db.transaction { create_cascade(result_class, request_class, header_class) }
|
|
114
|
+
else
|
|
115
|
+
create_cascade(result_class, request_class, header_class)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def poll_until(stop, result_class, request_class, errors, mutex)
|
|
120
|
+
until stop.call
|
|
121
|
+
result_class.order(Sequel.desc(:id)).limit(1).all
|
|
122
|
+
request_class.order(Sequel.desc(:id)).limit(1).all
|
|
123
|
+
sleep(READER_POLL_INTERVAL)
|
|
124
|
+
end
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
mutex.synchronize { errors << e }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def build_schema_and_models(db)
|
|
130
|
+
db.create_table(:results) do
|
|
131
|
+
primary_key :id
|
|
132
|
+
column :test_id, String
|
|
133
|
+
column :result, String
|
|
134
|
+
end
|
|
135
|
+
db.create_table(:requests) do
|
|
136
|
+
primary_key :id
|
|
137
|
+
foreign_key :result_id, :results
|
|
138
|
+
column :verb, String
|
|
139
|
+
end
|
|
140
|
+
db.create_table(:headers) do
|
|
141
|
+
primary_key :id
|
|
142
|
+
foreign_key :request_id, :requests
|
|
143
|
+
column :name, String
|
|
144
|
+
column :value, String
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
[
|
|
148
|
+
Class.new(Sequel::Model(db[:results])),
|
|
149
|
+
Class.new(Sequel::Model(db[:requests])),
|
|
150
|
+
Class.new(Sequel::Model(db[:headers]))
|
|
151
|
+
]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def create_cascade(result_class, request_class, header_class)
|
|
155
|
+
result = result_class.create(test_id: SecureRandom.uuid, result: 'pass')
|
|
156
|
+
3.times do
|
|
157
|
+
request = request_class.create(result_id: result.id, verb: 'GET')
|
|
158
|
+
2.times do |i|
|
|
159
|
+
header_class.create(request_id: request.id, name: "Header-#{i}", value: SecureRandom.hex(8))
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
result
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
data/lib/inferno/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: inferno_core
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.4.
|
|
4
|
+
version: 1.4.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stephen MacVicar
|
|
@@ -10,7 +10,7 @@ authors:
|
|
|
10
10
|
autorequire:
|
|
11
11
|
bindir: bin
|
|
12
12
|
cert_chain: []
|
|
13
|
-
date: 2026-
|
|
13
|
+
date: 2026-08-26 00:00:00.000000000 Z
|
|
14
14
|
dependencies:
|
|
15
15
|
- !ruby/object:Gem::Dependency
|
|
16
16
|
name: activesupport
|
|
@@ -678,6 +678,7 @@ files:
|
|
|
678
678
|
- lib/inferno/dsl/must_support_metadata_extractor.rb
|
|
679
679
|
- lib/inferno/dsl/oauth_credentials.rb
|
|
680
680
|
- lib/inferno/dsl/primitive_type.rb
|
|
681
|
+
- lib/inferno/dsl/profile_metadata.rb
|
|
681
682
|
- lib/inferno/dsl/request_storage.rb
|
|
682
683
|
- lib/inferno/dsl/requirement_set.rb
|
|
683
684
|
- lib/inferno/dsl/result_collection.rb
|
|
@@ -756,6 +757,7 @@ files:
|
|
|
756
757
|
- lib/inferno/route_storage.rb
|
|
757
758
|
- lib/inferno/spec_support.rb
|
|
758
759
|
- lib/inferno/test_runner.rb
|
|
760
|
+
- lib/inferno/utils/db_concurrency_check.rb
|
|
759
761
|
- lib/inferno/utils/execution_script_runner.rb
|
|
760
762
|
- lib/inferno/utils/ig_downloader.rb
|
|
761
763
|
- lib/inferno/utils/markdown_formatter.rb
|