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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8cffc2d9b151af63840789863f04e8551fd82077351f85d356b5a88f103f28f2
4
- data.tar.gz: c6687758255cf5acadb700dfd268ee65f6ab03713dbc17ec412d1240d7f352b7
3
+ metadata.gz: e77b2567061750720f73082094abe8f2906e060f362842640725481824dea6f3
4
+ data.tar.gz: 7b7ba8fa843ab58166f725b7939a74311825f959158b5eea06d954dbf1c9fe50
5
5
  SHA512:
6
- metadata.gz: 0f7ca455177f14a4f0ad5dae5852a7b7441e7ef867e554678b8d55410d623cb7b1d0e4e4432dbd8f1c8cdc085943801d2b98601d9ea776cd1e93f6482a02e3b9
7
- data.tar.gz: d4160ba11c06d24fc185c2b4cde86fc36c519499f383c79053246114b2e69d0e5be4504e87ea476f462e1439383f8c1a07a5efcab2701449cbd6458939faa13b
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
- slice_value = discriminator[:path].present? ? slice.send((discriminator[:path]).to_s)&.coding : slice.coding
256
- slice_value&.any? do |coding|
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
- slice_value = discriminator[:path].present? ? slice.send(discriminator[:path]) : slice
264
- slice_value&.code == discriminator[:code] && slice_value&.system == discriminator[:system]
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
- value[:system] == coding.system && value[:code] == coding.code
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 { |choice| choice[:slice_names]&.include?(slice[:name]) }
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? { |slice_name| missing_slices.none? { |slice| slice[:name] == slice_name } }
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[:slice_id] } +
191
- missing_extensions.map { |extension_definition| extension_definition[:id] }
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
- if element_definition[:fixed_value].present?
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
- # Elements are represented as their `path`, with the matched `fixed_value` appended after a
89
- # colon where present (eg `"code.coding.code:45473-6"`). Slices and extensions are
90
- # represented as their `path`, with their `slice_name` appended after a colon where present
91
- # (eg `"category:us-core"`, `"extension:us-core-race"`).
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
- Array.wrap(must_supports[:elements]).map do |element|
96
- must_support_string(element[:path], element[:fixed_value])
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 include_element.system.present? && include_element.filter&.empty?
64
- # Cannot process intensional value set with filters
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.concept.map { |concept| { system: bound_system.system, code: concept.code } }
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| [:id].include? 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)
@@ -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
- Inferno::Application['db.connection'].transaction do
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
- # If you change this, run `bundle exec rake db:check_concurrency` to verify
284
- # SQLITE_BUSY is still avoided under concurrent test-run writes and status
285
- # polling (see lib/inferno/utils/db_concurrency_check.rb for why that's a
286
- # rake task and not a spec).
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
  )
@@ -1,4 +1,4 @@
1
1
  module Inferno
2
2
  # Standard patterns for gem versions: https://guides.rubygems.org/patterns/
3
- VERSION = '1.4.5'.freeze
3
+ VERSION = '1.5.0'.freeze
4
4
  end
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.5
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-05 00:00:00.000000000 Z
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: 2.10.1
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: 2.10.1
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