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.
@@ -1,5 +1,3 @@
1
- /*! js-yaml 4.2.0 https://github.com/nodeca/js-yaml @license MIT */
2
-
3
1
  /*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/babel/babel/blob/main/packages/babel-helpers/LICENSE */
4
2
 
5
3
  /**
@@ -59,28 +59,24 @@ module Inferno
59
59
  end
60
60
 
61
61
  def run_test(test, scratch)
62
- inputs = load_inputs(test)
63
- input_json_string = inputs_as_json(test, inputs)
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
- result = evaluate_runnable_result(test, test_instance, inputs)
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
- outputs = save_outputs(test_instance)
76
- output_json_string = JSON.generate(outputs)
74
+ result = evaluate_runnable_result(test, test_instance, inputs)
77
75
 
78
- if result == 'wait'
79
- test_runs_repo.mark_as_waiting(test_run.id, test_instance.identifier, test_instance.wait_timeout)
80
- end
76
+ outputs = save_outputs(test_instance)
77
+ output_json_string = JSON.generate(outputs)
81
78
 
82
- test_result = persist_result(
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
- # If running a single test, update its parents' results. If running a
94
- # group or suite, #run_group handles updating the parents.
95
- return test_result if test_run.test_id.blank?
96
-
97
- update_parent_result(test.parent)
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
- test_result
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 = results_repo.create(
253
- params.merge(test_run_id: test_run.id, test_session_id: test_session.id)
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
@@ -1,4 +1,4 @@
1
1
  module Inferno
2
2
  # Standard patterns for gem versions: https://guides.rubygems.org/patterns/
3
- VERSION = '1.4.1'.freeze
3
+ VERSION = '1.4.3'.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.1
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-07-21 00:00:00.000000000 Z
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