inferno_core 1.4.5 → 1.5.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.
- checksums.yaml +4 -4
- data/lib/inferno/apps/web/router.rb +1 -1
- data/lib/inferno/config/boot/db.rb +0 -5
- data/lib/inferno/dsl/fhir_resource_navigation.rb +10 -5
- data/lib/inferno/dsl/must_support_assessment.rb +22 -44
- data/lib/inferno/dsl/must_support_strings.rb +62 -0
- data/lib/inferno/dsl/profile_metadata.rb +9 -18
- data/lib/inferno/dsl/value_extractor.rb +22 -4
- data/lib/inferno/entities/test_kit.rb +4 -2
- data/lib/inferno/jobs/execute_test_run.rb +3 -0
- data/lib/inferno/jobs/resume_test_run.rb +4 -0
- data/lib/inferno/test_runner.rb +10 -7
- data/lib/inferno/version.rb +1 -1
- metadata +5 -5
- data/lib/inferno/utils/db_concurrency_check.rb +0 -166
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e77b2567061750720f73082094abe8f2906e060f362842640725481824dea6f3
|
|
4
|
+
data.tar.gz: 7b7ba8fa843ab58166f725b7939a74311825f959158b5eea06d954dbf1c9fe50
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 19f220a5663640cc07eb0182321aad839126f89e0e85b3c7c9d98971c4cde72b591fc5c86013c3cd8db3fc07f01bd08847b5b98ed82213c4819d75ddcf46a1e5
|
|
7
|
+
data.tar.gz: c0b1ba31490d081c3e4d351efa9ac182996952366e20b96fa75ec9e7fb816c01dbdba3c7d857746b45455181a098ee2b78fd2b389b65d1b2d0ec7179504d4482
|
|
@@ -98,7 +98,7 @@ module Inferno
|
|
|
98
98
|
Inferno.routes.each do |route|
|
|
99
99
|
cleaned_id = route[:suite].id.gsub(/[^a-zA-Z\d\-._~]/, '_')
|
|
100
100
|
path = "/custom/#{cleaned_id}#{route[:path]}"
|
|
101
|
-
Application['logger'].info("Registering custom route: #{path}")
|
|
101
|
+
Application['logger'].info("Registering custom route: #{route[:method].to_s.upcase} #{path}")
|
|
102
102
|
if route[:method] == :all
|
|
103
103
|
mount route[:handler], at: path
|
|
104
104
|
else
|
|
@@ -16,11 +16,6 @@ module Inferno
|
|
|
16
16
|
# or writes from another. WAL mode lets readers proceed without blocking on a
|
|
17
17
|
# concurrent writer, and a longer busy_timeout gives a blocked writer more
|
|
18
18
|
# room to wait its turn instead of immediately raising SQLITE_BUSY.
|
|
19
|
-
#
|
|
20
|
-
# If you change this, run `bundle exec rake db:check_concurrency` to verify
|
|
21
|
-
# SQLITE_BUSY is still avoided under concurrent test-run writes and status
|
|
22
|
-
# polling (see lib/inferno/utils/db_concurrency_check.rb for why that's a
|
|
23
|
-
# rake task and not a spec).
|
|
24
19
|
def configure_sqlite_pragmas!(config)
|
|
25
20
|
return config unless config['adapter'] == 'sqlite'
|
|
26
21
|
|
|
@@ -252,16 +252,18 @@ module Inferno
|
|
|
252
252
|
|
|
253
253
|
# @private
|
|
254
254
|
def matching_pattern_codeable_concept_slice?(slice, discriminator)
|
|
255
|
-
|
|
256
|
-
|
|
255
|
+
codings = resolve_path(slice, discriminator[:path])
|
|
256
|
+
.flat_map { |codeable_concept| Array.wrap(codeable_concept.coding) }
|
|
257
|
+
codings.any? do |coding|
|
|
257
258
|
coding.code == discriminator[:code] && coding.system == discriminator[:system]
|
|
258
259
|
end
|
|
259
260
|
end
|
|
260
261
|
|
|
261
262
|
# @private
|
|
262
263
|
def matching_pattern_coding_slice?(slice, discriminator)
|
|
263
|
-
|
|
264
|
-
|
|
264
|
+
resolve_path(slice, discriminator[:path]).any? do |coding|
|
|
265
|
+
coding.code == discriminator[:code] && coding.system == discriminator[:system]
|
|
266
|
+
end
|
|
265
267
|
end
|
|
266
268
|
|
|
267
269
|
# @private
|
|
@@ -323,7 +325,10 @@ module Inferno
|
|
|
323
325
|
when String
|
|
324
326
|
value == coding.code
|
|
325
327
|
when Hash
|
|
326
|
-
|
|
328
|
+
next false unless value[:system] == coding.system
|
|
329
|
+
|
|
330
|
+
# A value without a code stands for every code in its system
|
|
331
|
+
value[:code].nil? ? coding.code.present? : value[:code] == coding.code
|
|
327
332
|
end
|
|
328
333
|
end
|
|
329
334
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require_relative 'must_support_strings'
|
|
2
|
+
|
|
1
3
|
module Inferno
|
|
2
4
|
module DSL
|
|
3
5
|
# The MustSupportAssessment module contains the logic for tests
|
|
@@ -23,7 +25,7 @@ module Inferno
|
|
|
23
25
|
# test kits can subclass to build these objects, eg from generated YAML, instead of hand-rolling one.
|
|
24
26
|
# @param requirement_extension [String] Extension URL that implies "required" as an alternative to the MS flag
|
|
25
27
|
# @yield [MustSupportMetadataExtractor] Customize the metadata before running the test
|
|
26
|
-
# @return [Array<String>] List of missing elements
|
|
28
|
+
# @return [Array<String>] List of missing elements, sorted alphabetically
|
|
27
29
|
def missing_must_support_elements(resources, profile_url, validator_name: :default, metadata: nil,
|
|
28
30
|
requirement_extension: nil, &)
|
|
29
31
|
debug_metadata = config.options[:debug_must_support_metadata]
|
|
@@ -141,7 +143,9 @@ module Inferno
|
|
|
141
143
|
|
|
142
144
|
def handle_must_support_slice_choices
|
|
143
145
|
missing_slices.delete_if do |slice|
|
|
144
|
-
choices = metadata.must_supports[:choices].find
|
|
146
|
+
choices = metadata.must_supports[:choices].find do |choice|
|
|
147
|
+
choice[:slice_names]&.any? { |slice_name| slice_identified_by?(slice, slice_name) }
|
|
148
|
+
end
|
|
145
149
|
any_choice_supported?(choices)
|
|
146
150
|
end
|
|
147
151
|
end
|
|
@@ -172,7 +176,15 @@ module Inferno
|
|
|
172
176
|
def any_slice_names_choice_supported?(choices)
|
|
173
177
|
return false unless choices[:slice_names].present?
|
|
174
178
|
|
|
175
|
-
choices[:slice_names].any?
|
|
179
|
+
choices[:slice_names].any? do |slice_name|
|
|
180
|
+
missing_slices.none? { |slice| slice_identified_by?(slice, slice_name) }
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Entries in a choice's slice_names may be either the slice name (eg 'us-core') or the slice id
|
|
185
|
+
# (eg 'Condition.category:us-core'). The id is unique within a profile while the name may not be.
|
|
186
|
+
def slice_identified_by?(slice, slice_name_or_id)
|
|
187
|
+
[slice[:slice_name], slice[:slice_id]].include?(slice_name_or_id)
|
|
176
188
|
end
|
|
177
189
|
|
|
178
190
|
def any_elements_choice_supported?(choices)
|
|
@@ -185,18 +197,17 @@ module Inferno
|
|
|
185
197
|
end
|
|
186
198
|
end
|
|
187
199
|
|
|
200
|
+
# See {MustSupportStrings} for how each missing item is represented. Sorted to match
|
|
201
|
+
# {ProfileMetadata#must_support_strings}.
|
|
188
202
|
def missing_must_support_strings
|
|
189
|
-
missing_elements.map { |element_definition| missing_element_string(element_definition) } +
|
|
190
|
-
missing_slices.map { |slice_definition| slice_definition
|
|
191
|
-
missing_extensions.map { |extension_definition| extension_definition
|
|
203
|
+
(missing_elements.map { |element_definition| missing_element_string(element_definition) } +
|
|
204
|
+
missing_slices.map { |slice_definition| MustSupportStrings.slice_string(slice_definition) } +
|
|
205
|
+
missing_extensions.map { |extension_definition| MustSupportStrings.extension_string(extension_definition) })
|
|
206
|
+
.sort
|
|
192
207
|
end
|
|
193
208
|
|
|
194
209
|
def missing_element_string(element_definition)
|
|
195
|
-
|
|
196
|
-
"#{element_definition[:path]}:#{element_definition[:fixed_value]}"
|
|
197
|
-
else
|
|
198
|
-
element_definition[:path]
|
|
199
|
-
end
|
|
210
|
+
MustSupportStrings.element_string(element_definition)
|
|
200
211
|
end
|
|
201
212
|
|
|
202
213
|
def must_support_extensions
|
|
@@ -303,28 +314,6 @@ module Inferno
|
|
|
303
314
|
]
|
|
304
315
|
end
|
|
305
316
|
|
|
306
|
-
def process_must_support_element_in_extension(resource, path)
|
|
307
|
-
return [resource, path] unless path.start_with?('extension:')
|
|
308
|
-
|
|
309
|
-
path_without_prefix = path.delete_prefix('extension:')
|
|
310
|
-
extension_split = path_without_prefix.split('.')
|
|
311
|
-
extension_name = extension_split.first
|
|
312
|
-
extension_path = extension_split.last
|
|
313
|
-
|
|
314
|
-
found_extension_url =
|
|
315
|
-
normalized_extension_url(must_support_extensions.find { |ex| ex[:id].include?(extension_name) }[:url])
|
|
316
|
-
ms_element_extension = resource.extension.find do |extension|
|
|
317
|
-
normalized_extension_url(extension.url) == found_extension_url
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
if ms_element_extension.present?
|
|
321
|
-
resource = ms_element_extension
|
|
322
|
-
path = extension_path
|
|
323
|
-
end
|
|
324
|
-
|
|
325
|
-
[resource, path]
|
|
326
|
-
end
|
|
327
|
-
|
|
328
317
|
def matching_without_extensions?(value, ms_extension_urls, fixed_value)
|
|
329
318
|
has_ms_extension = must_support_extension_present?(value, ms_extension_urls)
|
|
330
319
|
|
|
@@ -464,17 +453,6 @@ module Inferno
|
|
|
464
453
|
def find_slice_by_values(element, value_definitions)
|
|
465
454
|
Array.wrap(element).find { |el| verify_slice_by_values(el, value_definitions) }
|
|
466
455
|
end
|
|
467
|
-
|
|
468
|
-
def required_binding_value_match?(coding, values)
|
|
469
|
-
values.any? do |value|
|
|
470
|
-
case value
|
|
471
|
-
when String
|
|
472
|
-
value == coding.code
|
|
473
|
-
when Hash
|
|
474
|
-
value[:system] == coding.system && value[:code] == coding.code
|
|
475
|
-
end
|
|
476
|
-
end
|
|
477
|
-
end
|
|
478
456
|
end
|
|
479
457
|
end
|
|
480
458
|
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module Inferno
|
|
2
|
+
module DSL
|
|
3
|
+
# Builds the strings used to identify Must Support elements, slices, and extensions from their
|
|
4
|
+
# metadata, so that lists of what is expected to be supported (eg {ProfileMetadata#must_support_strings})
|
|
5
|
+
# and lists of what is missing (eg {MustSupportAssessment#missing_must_support_elements}) agree.
|
|
6
|
+
#
|
|
7
|
+
# Each item is represented by its ElementDefinition id (which includes the names of any parent
|
|
8
|
+
# slices) without the leading resource type, eg 'category:us-core' rather than
|
|
9
|
+
# 'Condition.category:us-core'. Elements with a fixed value have it appended, eg 'name.use = old'.
|
|
10
|
+
# @private
|
|
11
|
+
module MustSupportStrings
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @param element_definition [Hash] Must Support element metadata
|
|
15
|
+
# @return [String]
|
|
16
|
+
def element_string(element_definition)
|
|
17
|
+
path = element_display_path(element_definition)
|
|
18
|
+
|
|
19
|
+
fixed_value = element_definition[:fixed_value]
|
|
20
|
+
if fixed_value.present? || fixed_value == false
|
|
21
|
+
"#{path} = #{fixed_value}"
|
|
22
|
+
else
|
|
23
|
+
path
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param slice_definition [Hash] Must Support slice metadata
|
|
28
|
+
# @return [String]
|
|
29
|
+
def slice_string(slice_definition)
|
|
30
|
+
id_string(slice_definition, :slice_id)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param extension_definition [Hash] Must Support extension metadata
|
|
34
|
+
# @return [String]
|
|
35
|
+
def extension_string(extension_definition)
|
|
36
|
+
id_string(extension_definition, :id)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Elements under a choice type slice (eg value[x]:valueQuantity.value) have their navigation
|
|
40
|
+
# path rewritten so the slice name is mangled (eg valueQuantity:valueQuantity.value), so report
|
|
41
|
+
# the original path instead. Elements where only some types of a choice are Must Support
|
|
42
|
+
# (eg original path effective[x], path effectiveDateTime) keep their path since it names the type.
|
|
43
|
+
def element_display_path(element_definition)
|
|
44
|
+
original_path = element_definition[:original_path]
|
|
45
|
+
original_path&.include?('[x]:') ? original_path : element_definition[:path]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def id_string(definition, id_key)
|
|
49
|
+
id = definition[id_key]
|
|
50
|
+
raise ArgumentError, "Must Support metadata is missing #{id_key.inspect}: #{definition.inspect}" if id.blank?
|
|
51
|
+
|
|
52
|
+
without_resource_type(id)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Resource types are PascalCase while element names are camelCase, so a leading
|
|
56
|
+
# capitalized segment is the resource type prefix of an ElementDefinition id.
|
|
57
|
+
def without_resource_type(id)
|
|
58
|
+
id&.sub(/\A[A-Z][A-Za-z0-9]*\./, '')
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require_relative 'must_support_strings'
|
|
2
|
+
|
|
1
3
|
module Inferno
|
|
2
4
|
module DSL
|
|
3
5
|
# A ProfileMetadata object is a small, inheritable data holder for generated metadata about a
|
|
@@ -85,29 +87,18 @@ module Inferno
|
|
|
85
87
|
# check, this includes everything that's expected to be supported, not just what's absent
|
|
86
88
|
# from a particular set of resources.
|
|
87
89
|
#
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
# (eg `"
|
|
90
|
+
# Each item is represented the same way as in the list returned by a missing-elements check:
|
|
91
|
+
# by its ElementDefinition id (including the names of any parent slices) without the leading
|
|
92
|
+
# resource type (eg `"category:us-core"`, `"extension:us-core-race"`), with any matched
|
|
93
|
+
# `fixed_value` appended to elements (eg `"code.coding.code = 45473-6"`). See {MustSupportStrings}.
|
|
92
94
|
# @return [Array<String>]
|
|
93
95
|
def must_support_strings
|
|
94
|
-
element_strings =
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
end
|
|
98
|
-
slice_strings =
|
|
99
|
-
Array.wrap(must_supports[:slices]).map { |slice| must_support_string(slice[:path], slice[:slice_name]) }
|
|
100
|
-
extension_strings =
|
|
101
|
-
Array.wrap(must_supports[:extensions]).map { |ext| must_support_string(ext[:path], ext[:slice_name]) }
|
|
96
|
+
element_strings = Array.wrap(must_supports[:elements]).map { |el| MustSupportStrings.element_string(el) }
|
|
97
|
+
slice_strings = Array.wrap(must_supports[:slices]).map { |slice| MustSupportStrings.slice_string(slice) }
|
|
98
|
+
extension_strings = Array.wrap(must_supports[:extensions]).map { |ex| MustSupportStrings.extension_string(ex) }
|
|
102
99
|
|
|
103
100
|
(element_strings + slice_strings + extension_strings).sort
|
|
104
101
|
end
|
|
105
|
-
|
|
106
|
-
private
|
|
107
|
-
|
|
108
|
-
def must_support_string(path, suffix)
|
|
109
|
-
suffix.present? ? "#{path}:#{suffix}" : path
|
|
110
|
-
end
|
|
111
102
|
end
|
|
112
103
|
end
|
|
113
104
|
end
|
|
@@ -60,9 +60,8 @@ module Inferno
|
|
|
60
60
|
def bound_systems_from_valueset_include_element(include_element)
|
|
61
61
|
if include_element.concept.present?
|
|
62
62
|
include_element
|
|
63
|
-
elsif
|
|
64
|
-
|
|
65
|
-
ig_resources.code_system_by_url(include_element.system)
|
|
63
|
+
elsif whole_code_system_include?(include_element)
|
|
64
|
+
whole_code_system(include_element)
|
|
66
65
|
elsif include_element.valueSet.present?
|
|
67
66
|
include_element.valueSet.map do |vs|
|
|
68
67
|
a_value_set = ig_resources.value_set_by_url(vs)
|
|
@@ -71,6 +70,18 @@ module Inferno
|
|
|
71
70
|
end
|
|
72
71
|
end
|
|
73
72
|
|
|
73
|
+
def whole_code_system_include?(include_element)
|
|
74
|
+
include_element.system.present? && include_element.concept.blank? &&
|
|
75
|
+
include_element.filter&.empty? && include_element.valueSet.blank?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# An include of a whole code system lists no codes. Use the code system's concepts when the IG
|
|
79
|
+
# has them, otherwise keep the include so that any code from its system is accepted.
|
|
80
|
+
def whole_code_system(include_element)
|
|
81
|
+
code_system = ig_resources.code_system_by_url(include_element.system)
|
|
82
|
+
code_system&.concept.present? ? code_system : include_element
|
|
83
|
+
end
|
|
84
|
+
|
|
74
85
|
def codes_from_value_set_binding(the_element)
|
|
75
86
|
codes_from_system_code_pair(codings_from_value_set_binding(the_element))
|
|
76
87
|
end
|
|
@@ -99,7 +110,7 @@ module Inferno
|
|
|
99
110
|
bound_systems.flat_map do |bound_system|
|
|
100
111
|
case bound_system
|
|
101
112
|
when FHIR::ValueSet::Compose::Include
|
|
102
|
-
bound_system
|
|
113
|
+
codings_from_include(bound_system)
|
|
103
114
|
when FHIR::CodeSystem
|
|
104
115
|
bound_system.concept.map { |concept| { system: bound_system.url, code: concept.code } }
|
|
105
116
|
else
|
|
@@ -108,6 +119,13 @@ module Inferno
|
|
|
108
119
|
end.uniq
|
|
109
120
|
end
|
|
110
121
|
|
|
122
|
+
# A whole code system include becomes a system-only value
|
|
123
|
+
def codings_from_include(include_element)
|
|
124
|
+
return [{ system: include_element.system }] if include_element.concept.blank?
|
|
125
|
+
|
|
126
|
+
include_element.concept.map { |concept| { system: include_element.system, code: concept.code } }
|
|
127
|
+
end
|
|
128
|
+
|
|
111
129
|
def value_set_expansion_contains(element)
|
|
112
130
|
value_set(element)&.expansion&.contains
|
|
113
131
|
end
|
|
@@ -168,9 +168,11 @@ module Inferno
|
|
|
168
168
|
end
|
|
169
169
|
|
|
170
170
|
# @private
|
|
171
|
-
def copy_instance_variables
|
|
171
|
+
def copy_instance_variables(subclass)
|
|
172
|
+
# The id must be unique, and the suites and options are memoized from
|
|
173
|
+
# suite_ids, which the subclass may change.
|
|
172
174
|
instance_variables
|
|
173
|
-
.reject { |variable| [
|
|
175
|
+
.reject { |variable| [:@id, :@suites, :@options].include? variable }
|
|
174
176
|
.each { |variable| subclass.instance_variable_set(variable, instance_variable_get(variable).dup) }
|
|
175
177
|
end
|
|
176
178
|
end
|
|
@@ -3,6 +3,9 @@ module Inferno
|
|
|
3
3
|
class ExecuteTestRun
|
|
4
4
|
include Sidekiq::Worker
|
|
5
5
|
|
|
6
|
+
# Retrying would replay the whole test run, so surface failures instead.
|
|
7
|
+
sidekiq_options retry: false
|
|
8
|
+
|
|
6
9
|
def perform(test_run_id)
|
|
7
10
|
test_run = Inferno::Repositories::TestRuns.new.find(test_run_id)
|
|
8
11
|
test_session = Inferno::Repositories::TestSessions.new.find(test_run.test_session_id)
|
|
@@ -3,6 +3,10 @@ module Inferno
|
|
|
3
3
|
class ResumeTestRun
|
|
4
4
|
include Sidekiq::Worker
|
|
5
5
|
|
|
6
|
+
# Retrying would rerun the test that was in progress, resending its
|
|
7
|
+
# requests, so surface failures instead.
|
|
8
|
+
sidekiq_options retry: false
|
|
9
|
+
|
|
6
10
|
def perform(test_run_id)
|
|
7
11
|
test_run = Inferno::Repositories::TestRuns.new.find(test_run_id)
|
|
8
12
|
test_session = Inferno::Repositories::TestSessions.new.find(test_run.test_session_id)
|
data/lib/inferno/test_runner.rb
CHANGED
|
@@ -89,8 +89,12 @@ module Inferno
|
|
|
89
89
|
if result == 'wait'
|
|
90
90
|
# The waiting status and the wait result must become visible to
|
|
91
91
|
# readers atomically, so that pollers never see the run waiting
|
|
92
|
-
# without the waiting test's result being present.
|
|
93
|
-
|
|
92
|
+
# without the waiting test's result being present. Immediate mode
|
|
93
|
+
# takes the sqlite write lock up front () mark_as_waiting reads before
|
|
94
|
+
# it writes) and is used to avoid a SQLITE_BUSY error without waiting
|
|
95
|
+
# out busy_timeout when it tries to upgrade to a write lock that is
|
|
96
|
+
# already taken.
|
|
97
|
+
Inferno::Application['db.connection'].transaction(mode: :immediate) do
|
|
94
98
|
test_runs_repo.mark_as_waiting(test_run.id, test_instance.identifier, test_instance.wait_timeout)
|
|
95
99
|
persist_result(result_params)
|
|
96
100
|
end
|
|
@@ -280,11 +284,10 @@ module Inferno
|
|
|
280
284
|
# into a single lock acquisition instead of one per insert, cutting
|
|
281
285
|
# down on sqlite write-lock contention with concurrent readers/writers.
|
|
282
286
|
#
|
|
283
|
-
#
|
|
284
|
-
#
|
|
285
|
-
#
|
|
286
|
-
|
|
287
|
-
result = Inferno::Application['db.connection'].transaction do
|
|
287
|
+
# Immediate mode takes the write lock up front so that any reads inside
|
|
288
|
+
# the transaction can't leave it stuck unable to upgrade to a write lock
|
|
289
|
+
# (see the wait transaction in #run_test).
|
|
290
|
+
result = Inferno::Application['db.connection'].transaction(mode: :immediate) do
|
|
288
291
|
results_repo.create(
|
|
289
292
|
params.merge(test_run_id: test_run.id, test_session_id: test_session.id)
|
|
290
293
|
)
|
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
|
+
version: 1.5.0
|
|
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-10-
|
|
13
|
+
date: 2026-10-21 00:00:00.000000000 Z
|
|
14
14
|
dependencies:
|
|
15
15
|
- !ruby/object:Gem::Dependency
|
|
16
16
|
name: activesupport
|
|
@@ -400,14 +400,14 @@ dependencies:
|
|
|
400
400
|
requirements:
|
|
401
401
|
- - "~>"
|
|
402
402
|
- !ruby/object:Gem::Version
|
|
403
|
-
version:
|
|
403
|
+
version: 3.0.0
|
|
404
404
|
type: :runtime
|
|
405
405
|
prerelease: false
|
|
406
406
|
version_requirements: !ruby/object:Gem::Requirement
|
|
407
407
|
requirements:
|
|
408
408
|
- - "~>"
|
|
409
409
|
- !ruby/object:Gem::Version
|
|
410
|
-
version:
|
|
410
|
+
version: 3.0.0
|
|
411
411
|
- !ruby/object:Gem::Dependency
|
|
412
412
|
name: sequel
|
|
413
413
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -678,6 +678,7 @@ files:
|
|
|
678
678
|
- lib/inferno/dsl/messages.rb
|
|
679
679
|
- lib/inferno/dsl/must_support_assessment.rb
|
|
680
680
|
- lib/inferno/dsl/must_support_metadata_extractor.rb
|
|
681
|
+
- lib/inferno/dsl/must_support_strings.rb
|
|
681
682
|
- lib/inferno/dsl/oauth_credentials.rb
|
|
682
683
|
- lib/inferno/dsl/primitive_type.rb
|
|
683
684
|
- lib/inferno/dsl/profile_metadata.rb
|
|
@@ -759,7 +760,6 @@ files:
|
|
|
759
760
|
- lib/inferno/route_storage.rb
|
|
760
761
|
- lib/inferno/spec_support.rb
|
|
761
762
|
- lib/inferno/test_runner.rb
|
|
762
|
-
- lib/inferno/utils/db_concurrency_check.rb
|
|
763
763
|
- lib/inferno/utils/execution_script_runner.rb
|
|
764
764
|
- lib/inferno/utils/ig_downloader.rb
|
|
765
765
|
- lib/inferno/utils/markdown_formatter.rb
|
|
@@ -1,166 +0,0 @@
|
|
|
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
|