rspec-tracer 1.2.3 → 2.0.0.pre.2

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.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +384 -67
  3. data/README.md +454 -429
  4. data/bin/rspec-tracer +15 -0
  5. data/lib/rspec_tracer/cache/Rakefile +43 -0
  6. data/lib/rspec_tracer/cli/cache_clear.rb +111 -0
  7. data/lib/rspec_tracer/cli/cache_info.rb +104 -0
  8. data/lib/rspec_tracer/cli/doctor.rb +284 -0
  9. data/lib/rspec_tracer/cli/explain.rb +158 -0
  10. data/lib/rspec_tracer/cli/report_open.rb +82 -0
  11. data/lib/rspec_tracer/cli.rb +116 -0
  12. data/lib/rspec_tracer/configuration.rb +1196 -3
  13. data/lib/rspec_tracer/engine.rb +1168 -0
  14. data/lib/rspec_tracer/example.rb +141 -11
  15. data/lib/rspec_tracer/filter.rb +35 -0
  16. data/lib/rspec_tracer/line_stub.rb +61 -0
  17. data/lib/rspec_tracer/load_config.rb +2 -2
  18. data/lib/rspec_tracer/logger.rb +15 -0
  19. data/lib/rspec_tracer/rails/README.md +78 -0
  20. data/lib/rspec_tracer/rails/i18n_tracking.rb +137 -0
  21. data/lib/rspec_tracer/rails/notifications.rb +263 -0
  22. data/lib/rspec_tracer/rails/preset.rb +94 -0
  23. data/lib/rspec_tracer/rails/railtie.rb +22 -0
  24. data/lib/rspec_tracer/rails.rb +15 -0
  25. data/lib/rspec_tracer/remote_cache/README.md +140 -0
  26. data/lib/rspec_tracer/remote_cache/Rakefile +35 -11
  27. data/lib/rspec_tracer/remote_cache/archive.rb +137 -0
  28. data/lib/rspec_tracer/remote_cache/backend.rb +73 -0
  29. data/lib/rspec_tracer/remote_cache/git_ancestry.rb +241 -0
  30. data/lib/rspec_tracer/remote_cache/local_fs_backend.rb +439 -0
  31. data/lib/rspec_tracer/remote_cache/redis_backend.rb +554 -0
  32. data/lib/rspec_tracer/remote_cache/s3_backend.rb +712 -0
  33. data/lib/rspec_tracer/remote_cache/user_tasks.rb +436 -0
  34. data/lib/rspec_tracer/remote_cache/validator.rb +40 -62
  35. data/lib/rspec_tracer/remote_cache.rb +22 -0
  36. data/lib/rspec_tracer/reporters/README.md +103 -0
  37. data/lib/rspec_tracer/reporters/base.rb +87 -0
  38. data/lib/rspec_tracer/reporters/coverage_json_reporter.rb +338 -0
  39. data/lib/rspec_tracer/reporters/html/.gitignore +19 -0
  40. data/lib/rspec_tracer/reporters/html/.prettierignore +4 -0
  41. data/lib/rspec_tracer/reporters/html/.prettierrc.json +9 -0
  42. data/lib/rspec_tracer/reporters/html/README.md +80 -0
  43. data/lib/rspec_tracer/reporters/html/dist/assets/index.css +2 -0
  44. data/lib/rspec_tracer/reporters/html/dist/assets/index.js +1 -0
  45. data/lib/rspec_tracer/reporters/html/dist/index.html +24 -0
  46. data/lib/rspec_tracer/reporters/html/eslint.config.js +62 -0
  47. data/lib/rspec_tracer/reporters/html/package-lock.json +4941 -0
  48. data/lib/rspec_tracer/reporters/html/package.json +29 -0
  49. data/lib/rspec_tracer/reporters/html/src/app.jsx +130 -0
  50. data/lib/rspec_tracer/reporters/html/src/components/AllExamples.jsx +86 -0
  51. data/lib/rspec_tracer/reporters/html/src/components/DuplicateExamples.jsx +68 -0
  52. data/lib/rspec_tracer/reporters/html/src/components/ExamplesDependency.jsx +78 -0
  53. data/lib/rspec_tracer/reporters/html/src/components/FilesDependency.jsx +72 -0
  54. data/lib/rspec_tracer/reporters/html/src/components/FlakyExamples.jsx +42 -0
  55. data/lib/rspec_tracer/reporters/html/src/components/ReportTable.jsx +131 -0
  56. data/lib/rspec_tracer/reporters/html/src/components/SearchBar.jsx +19 -0
  57. data/lib/rspec_tracer/reporters/html/src/index.html +23 -0
  58. data/lib/rspec_tracer/reporters/html/src/main.jsx +37 -0
  59. data/lib/rspec_tracer/reporters/html/src/styles.css +434 -0
  60. data/lib/rspec_tracer/reporters/html/vite.config.js +42 -0
  61. data/lib/rspec_tracer/reporters/html_reporter.rb +266 -0
  62. data/lib/rspec_tracer/reporters/json_reporter.rb +88 -0
  63. data/lib/rspec_tracer/reporters/payload_builder.rb +235 -0
  64. data/lib/rspec_tracer/reporters/registry.rb +120 -0
  65. data/lib/rspec_tracer/reporters/terminal_reporter.rb +264 -0
  66. data/lib/rspec_tracer/rspec/README.md +73 -0
  67. data/lib/rspec_tracer/rspec/installation.rb +97 -0
  68. data/lib/rspec_tracer/rspec/metadata.rb +96 -0
  69. data/lib/rspec_tracer/rspec/parallel_tests.rb +459 -0
  70. data/lib/rspec_tracer/rspec/reporter_hook.rb +84 -0
  71. data/lib/rspec_tracer/rspec/runner_hook.rb +239 -0
  72. data/lib/rspec_tracer/source_file.rb +24 -7
  73. data/lib/rspec_tracer/storage/README.md +35 -0
  74. data/lib/rspec_tracer/storage/backend.rb +130 -0
  75. data/lib/rspec_tracer/storage/json_backend.rb +884 -0
  76. data/lib/rspec_tracer/storage/lazy_snapshot.rb +65 -0
  77. data/lib/rspec_tracer/storage/schema.rb +50 -0
  78. data/lib/rspec_tracer/storage/serializer/json.rb +41 -0
  79. data/lib/rspec_tracer/storage/serializer/msgpack.rb +167 -0
  80. data/lib/rspec_tracer/storage/snapshot.rb +141 -0
  81. data/lib/rspec_tracer/storage/sqlite_backend.rb +693 -0
  82. data/lib/rspec_tracer/time_formatter.rb +37 -18
  83. data/lib/rspec_tracer/tracker/README.md +36 -0
  84. data/lib/rspec_tracer/tracker/coverage_adapter.rb +174 -0
  85. data/lib/rspec_tracer/tracker/declared_globs.rb +100 -0
  86. data/lib/rspec_tracer/tracker/dependency_graph.rb +134 -0
  87. data/lib/rspec_tracer/tracker/env_matcher.rb +127 -0
  88. data/lib/rspec_tracer/tracker/env_snapshot.rb +77 -0
  89. data/lib/rspec_tracer/tracker/example_registry.rb +153 -0
  90. data/lib/rspec_tracer/tracker/file_digest.rb +61 -0
  91. data/lib/rspec_tracer/tracker/filter.rb +127 -0
  92. data/lib/rspec_tracer/tracker/input.rb +99 -0
  93. data/lib/rspec_tracer/tracker/io_hooks/file.rb +55 -0
  94. data/lib/rspec_tracer/tracker/io_hooks/io.rb +24 -0
  95. data/lib/rspec_tracer/tracker/io_hooks/json.rb +23 -0
  96. data/lib/rspec_tracer/tracker/io_hooks/kernel.rb +26 -0
  97. data/lib/rspec_tracer/tracker/io_hooks/yaml.rb +38 -0
  98. data/lib/rspec_tracer/tracker/io_hooks.rb +195 -0
  99. data/lib/rspec_tracer/tracker/loaded_files_tracker.rb +295 -0
  100. data/lib/rspec_tracer/tracker/new_file_detector.rb +62 -0
  101. data/lib/rspec_tracer/tracker/whole_suite_invalidators.rb +96 -0
  102. data/lib/rspec_tracer/version.rb +4 -1
  103. data/lib/rspec_tracer.rb +231 -491
  104. metadata +94 -43
  105. data/lib/rspec_tracer/cache.rb +0 -207
  106. data/lib/rspec_tracer/coverage_merger.rb +0 -42
  107. data/lib/rspec_tracer/coverage_reporter.rb +0 -187
  108. data/lib/rspec_tracer/coverage_writer.rb +0 -58
  109. data/lib/rspec_tracer/html_reporter/Rakefile +0 -18
  110. data/lib/rspec_tracer/html_reporter/assets/javascripts/application.js +0 -56
  111. data/lib/rspec_tracer/html_reporter/assets/javascripts/libraries/jquery.js +0 -10881
  112. data/lib/rspec_tracer/html_reporter/assets/javascripts/plugins/datatables.js +0 -15381
  113. data/lib/rspec_tracer/html_reporter/assets/stylesheets/application.css +0 -196
  114. data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/datatables.css +0 -459
  115. data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/jquery-ui.css +0 -436
  116. data/lib/rspec_tracer/html_reporter/assets/stylesheets/print.css +0 -92
  117. data/lib/rspec_tracer/html_reporter/assets/stylesheets/reset.css +0 -265
  118. data/lib/rspec_tracer/html_reporter/public/application.css +0 -5
  119. data/lib/rspec_tracer/html_reporter/public/application.js +0 -6
  120. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc.png +0 -0
  121. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc_disabled.png +0 -0
  122. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_both.png +0 -0
  123. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc.png +0 -0
  124. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc_disabled.png +0 -0
  125. data/lib/rspec_tracer/html_reporter/public/favicon.png +0 -0
  126. data/lib/rspec_tracer/html_reporter/public/loading.gif +0 -0
  127. data/lib/rspec_tracer/html_reporter/reporter.rb +0 -242
  128. data/lib/rspec_tracer/html_reporter/views/duplicate_examples.erb +0 -34
  129. data/lib/rspec_tracer/html_reporter/views/examples.erb +0 -58
  130. data/lib/rspec_tracer/html_reporter/views/examples_dependency.erb +0 -36
  131. data/lib/rspec_tracer/html_reporter/views/files_dependency.erb +0 -36
  132. data/lib/rspec_tracer/html_reporter/views/flaky_examples.erb +0 -38
  133. data/lib/rspec_tracer/html_reporter/views/layout.erb +0 -38
  134. data/lib/rspec_tracer/remote_cache/aws.rb +0 -176
  135. data/lib/rspec_tracer/remote_cache/cache.rb +0 -75
  136. data/lib/rspec_tracer/remote_cache/repo.rb +0 -210
  137. data/lib/rspec_tracer/report_generator.rb +0 -158
  138. data/lib/rspec_tracer/report_merger.rb +0 -68
  139. data/lib/rspec_tracer/report_writer.rb +0 -141
  140. data/lib/rspec_tracer/reporter.rb +0 -204
  141. data/lib/rspec_tracer/rspec_reporter.rb +0 -41
  142. data/lib/rspec_tracer/rspec_runner.rb +0 -56
  143. data/lib/rspec_tracer/ruby_coverage.rb +0 -9
  144. data/lib/rspec_tracer/runner.rb +0 -278
@@ -0,0 +1,693 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'set'
6
+ require 'time'
7
+
8
+ require_relative 'backend'
9
+ require_relative 'lazy_snapshot'
10
+ require_relative 'schema'
11
+ require_relative 'snapshot'
12
+
13
+ module RSpecTracer
14
+ # Internal Storage — see {RSpecTracer} for the user-facing surface.
15
+ # @api private
16
+ module Storage
17
+ # SQLite-on-disk storage backend. Single file
18
+ # `cache_path/rspec_tracer.sqlite3` with a normalized 9-table
19
+ # schema so a warm run can materialize only the rows a given
20
+ # field needs (Filter.select hits ~50-500 example_ids out of
21
+ # millions, JsonBackend eagerly read the whole map). Breaks
22
+ # the RAM-scales-with-cache-size curve that JsonBackend cannot
23
+ # escape without a different on-disk shape.
24
+ #
25
+ # MRI-only. The `sqlite3` gem targets MRI's C API; JRuby's
26
+ # `jdbc-sqlite3` has a different API that this backend does
27
+ # not target in 2.0. Users who select `:sqlite` on JRuby
28
+ # (or on MRI without the `sqlite3` gem in their Gemfile) see
29
+ # the construct raise `SqliteBackendError`, which the Engine's
30
+ # backend dispatch converts to a warn + cold-run fallback.
31
+ #
32
+ # No multi-run history. SqliteBackend stores only the latest
33
+ # run; save_graph full-replaces every table inside a single
34
+ # `BEGIN IMMEDIATE` transaction. JsonBackend's
35
+ # `cache_retention_local_count` is a no-op here. Users who
36
+ # need rollback history stay on `:json`.
37
+ #
38
+ # `journal_mode = MEMORY` so SQLite's WAL / SHM sidecars do not
39
+ # leak into the user-facing `rspec_tracer_cache/` directory
40
+ # (USER_FACING_SURFACE.md section 6 locks the layout; sidecars
41
+ # would surprise debug scripts that walk the dir expecting only
42
+ # documented files).
43
+ # rubocop:disable Metrics/ClassLength
44
+ class SqliteBackend
45
+ # Raised when the sqlite3 gem cannot be loaded. Engine's
46
+ # build_storage_backend dispatch rescues + falls back to
47
+ # `:json` with a warn line - same optional-dep posture as
48
+ # RedisBackend uses for the redis gem.
49
+ class SqliteBackendError < StandardError; end
50
+
51
+ # Internal constant.
52
+ # @api private
53
+ DB_FILENAME = 'rspec_tracer.sqlite3'
54
+ # Internal constant.
55
+ # @api private
56
+ JOURNAL_MODE_SQL = 'PRAGMA journal_mode = MEMORY'
57
+ # Internal constant.
58
+ # @api private
59
+ SYNCHRONOUS_SQL = 'PRAGMA synchronous = NORMAL'
60
+
61
+ # Two concurrent save_graph calls (parallel_tests workers, fork-
62
+ # based test harnesses) both reach `BEGIN IMMEDIATE` and contend
63
+ # on SQLite's RESERVED write lock. Without busy_timeout, the
64
+ # losing writer raises SQLite3::BusyException immediately. 5000
65
+ # ms gives ~5x margin over a worst-case 1 s save on large caches
66
+ # (cache_load benchmark p50 ~0.6 s at 500 examples), preserving
67
+ # the storage layer's "concurrent writers serialize cleanly"
68
+ # contract verified in spec/edge_cases/concurrent_write_spec.rb.
69
+ BUSY_TIMEOUT_MS = 5_000
70
+
71
+ # SQLite's on-disk file format starts with the literal bytes
72
+ # `SQLite format 3\000` (16 bytes). A file under db_path whose
73
+ # leading bytes differ is not a valid database - corrupted bytes,
74
+ # zero-length file, or a foreign file collision. Per the
75
+ # architecture's graceful-degradation rule (load_graph never
76
+ # raises; finalize writes a fresh cache), save_graph self-heals
77
+ # by deleting the corrupt file before opening, so the subsequent
78
+ # Database.new + ensure_schema! sequence writes a fresh db.
79
+ # Without this, configure_connection's PRAGMA raises
80
+ # SQLite3::NotADatabaseException and the user is stuck unless
81
+ # they manually rm the cache. The `cache_loader_fuzz` harness
82
+ # surfaced this gap.
83
+ SQLITE_MAGIC_BYTES = "SQLite format 3\x00".b.freeze
84
+ private_constant :SQLITE_MAGIC_BYTES
85
+
86
+ # Internal constant.
87
+ # @api private
88
+ STATUS_FIELDS = {
89
+ interrupted_examples: 'interrupted',
90
+ flaky_examples: 'flaky',
91
+ failed_examples: 'failed',
92
+ pending_examples: 'pending',
93
+ skipped_examples: 'skipped'
94
+ }.freeze
95
+
96
+ # Internal constant.
97
+ # @api private
98
+ DIGEST_MAP_KINDS = {
99
+ boot_set: 'boot',
100
+ wsi_snapshot: 'wsi',
101
+ env_snapshot: 'env'
102
+ }.freeze
103
+
104
+ # Mirrors FIELD_FILENAMES from JsonBackend - the set of Snapshot
105
+ # fields a LazySnapshot reader may ask about. Used by
106
+ # SqliteFieldReader to reject unknown fields before hitting the
107
+ # DB. Kept in step with Snapshot.members minus the envelope.
108
+ READABLE_FIELDS = (
109
+ %i[all_examples duplicate_examples all_files dependency
110
+ reverse_dependency examples_coverage env_dependency cache_hit_reason
111
+ filtered_examples] +
112
+ STATUS_FIELDS.keys + DIGEST_MAP_KINDS.keys
113
+ ).freeze
114
+
115
+ # Binds a SqliteBackend so LazySnapshot readers can dispatch
116
+ # one field at a time without threading a connection through.
117
+ class SqliteFieldReader
118
+ # Internal method on the tracer pipeline.
119
+ # @api private
120
+ def initialize(backend:)
121
+ @backend = backend
122
+ end
123
+
124
+ # Internal method on the tracer pipeline.
125
+ # @api private
126
+ def read(field)
127
+ @backend.read_field(field)
128
+ end
129
+ end
130
+
131
+ # Internal attribute.
132
+ # @api private
133
+ attr_reader :cache_path
134
+
135
+ # Internal method on the tracer pipeline.
136
+ # @api private
137
+ def initialize(cache_path:, logger: nil)
138
+ @cache_path = File.expand_path(cache_path)
139
+ @logger = logger
140
+ load_sqlite_driver!
141
+ end
142
+
143
+ # Internal method on the tracer pipeline.
144
+ # @api private
145
+ def last_run_id
146
+ with_connection do |db|
147
+ row = db.get_first_row('SELECT run_id FROM meta LIMIT 1')
148
+ run_id = row&.first
149
+ return nil if run_id.nil? || run_id.to_s.empty?
150
+
151
+ run_id
152
+ end
153
+ rescue StandardError
154
+ nil
155
+ end
156
+
157
+ # Internal method on the tracer pipeline.
158
+ # @api private
159
+ def load_graph(schema_version:)
160
+ with_connection do |db|
161
+ return nil unless meta_table_exists?(db)
162
+
163
+ row = db.get_first_row('SELECT schema_version, run_id FROM meta LIMIT 1')
164
+ return nil if row.nil?
165
+
166
+ stored_sv, run_id = row
167
+ unless Schema.supported?(stored_sv) && stored_sv == schema_version
168
+ info("schema_version mismatch (stored=#{stored_sv.inspect}, expected=#{schema_version}); cold run")
169
+ return nil
170
+ end
171
+ return nil if run_id.nil? || run_id.to_s.empty?
172
+
173
+ LazySnapshot.new(
174
+ schema_version: stored_sv, run_id: run_id,
175
+ reader: SqliteFieldReader.new(backend: self)
176
+ )
177
+ end
178
+ rescue StandardError => e
179
+ info("failed to load sqlite cache: #{e.class}: #{e.message}; cold run")
180
+ nil
181
+ end
182
+
183
+ # Internal method on the tracer pipeline.
184
+ # @api private
185
+ def save_graph(snapshot, schema_version:)
186
+ raise ArgumentError, 'snapshot must not be nil' if snapshot.nil?
187
+
188
+ unless Schema.supported?(schema_version)
189
+ raise ArgumentError, "unsupported schema_version: #{schema_version.inspect}"
190
+ end
191
+
192
+ run_id = snapshot.run_id
193
+ raise ArgumentError, 'snapshot.run_id must be a non-empty string' if run_id.nil? || run_id.to_s.empty?
194
+
195
+ transactional_save do
196
+ db = @active_db
197
+ write_all_tables(db, snapshot, schema_version: schema_version)
198
+ end
199
+
200
+ snapshot
201
+ end
202
+
203
+ # Internal method on the tracer pipeline.
204
+ # @api private
205
+ def clear!
206
+ return unless File.directory?(@cache_path)
207
+
208
+ FileUtils.rm_rf(@cache_path)
209
+ end
210
+
211
+ # Internal method on the tracer pipeline.
212
+ # @api private
213
+ def transactional_save(&block)
214
+ raise ArgumentError, 'block required' unless block
215
+
216
+ FileUtils.mkdir_p(@cache_path)
217
+ with_write_connection do |db|
218
+ @active_db = db
219
+ db.transaction(:immediate, &block)
220
+ ensure
221
+ @active_db = nil
222
+ end
223
+ end
224
+
225
+ # Read one field on behalf of a LazySnapshot. Missing table or
226
+ # empty result returns the per-field empty default (Set for
227
+ # id-set fields, {} for hashes) so partial caches behave
228
+ # identically to JsonBackend under the same conditions.
229
+ # ArgumentError on an unknown field is a programmer mistake and
230
+ # propagates; the StandardError rescue is only for wire / I/O
231
+ # failures on the DB itself.
232
+ def read_field(field)
233
+ raise ArgumentError, "unknown snapshot field: #{field.inspect}" unless READABLE_FIELDS.include?(field)
234
+
235
+ begin
236
+ with_connection { |db| dispatch_read(db, field) }
237
+ rescue StandardError => e
238
+ info("sqlite read_field(#{field.inspect}) failed: #{e.class}: #{e.message}; returning empty default")
239
+ empty_default_for(field)
240
+ end
241
+ end
242
+
243
+ private
244
+
245
+ # Internal method on the tracer pipeline.
246
+ # @api private
247
+ def db_path
248
+ File.join(@cache_path, DB_FILENAME)
249
+ end
250
+
251
+ # Internal method on the tracer pipeline.
252
+ # @api private
253
+ def load_sqlite_driver!
254
+ require 'sqlite3'
255
+ rescue LoadError
256
+ raise SqliteBackendError,
257
+ "sqlite3 gem is not installed; add `gem 'sqlite3'` to your Gemfile " \
258
+ '(MRI only; pin `~> 1.7` on Ruby 3.1 or `~> 2.0` on Ruby >= 3.2) ' \
259
+ 'or switch to `storage_backend :json`.'
260
+ end
261
+
262
+ # Internal method on the tracer pipeline.
263
+ # @api private
264
+ def with_connection
265
+ return nil unless File.file?(db_path)
266
+
267
+ db = ::SQLite3::Database.new(db_path)
268
+ configure_connection(db)
269
+ yield db
270
+ ensure
271
+ db&.close
272
+ end
273
+
274
+ # Internal method on the tracer pipeline.
275
+ # @api private
276
+ def with_write_connection
277
+ FileUtils.mkdir_p(@cache_path)
278
+ reset_corrupt_db_file!
279
+ db = ::SQLite3::Database.new(db_path)
280
+ configure_connection(db)
281
+ ensure_schema!(db)
282
+ yield db
283
+ ensure
284
+ db&.close
285
+ end
286
+
287
+ # Probe db_path's leading bytes against SQLite's magic header.
288
+ # Random bytes / zero-length / foreign collision -> delete so
289
+ # save_graph can write fresh. Cheap (one 16-byte read); avoids
290
+ # the SQLite3::NotADatabaseException path on configure_connection.
291
+ def reset_corrupt_db_file!
292
+ return unless File.file?(db_path)
293
+ return if File.size(db_path) >= SQLITE_MAGIC_BYTES.bytesize &&
294
+ File.binread(db_path, SQLITE_MAGIC_BYTES.bytesize) == SQLITE_MAGIC_BYTES
295
+
296
+ info("sqlite: cache file at #{db_path} is not a valid SQLite database; resetting for cold-cache rewrite")
297
+ File.delete(db_path)
298
+ end
299
+
300
+ # Internal method on the tracer pipeline.
301
+ # @api private
302
+ def configure_connection(db)
303
+ # Set busy_timeout FIRST. journal_mode = MEMORY itself requires
304
+ # the file's write lock to switch modes; if another connection is
305
+ # mid-transaction (BEGIN IMMEDIATE held), the PRAGMA contends on
306
+ # that lock and raises SQLite3::BusyException unless busy_timeout
307
+ # has been configured. CI on Ruby 3.3 surfaced this: the
308
+ # losing concurrent writer never reached its BEGIN IMMEDIATE
309
+ # because configure_connection's PRAGMA failed first.
310
+ db.busy_timeout = BUSY_TIMEOUT_MS
311
+ db.execute(JOURNAL_MODE_SQL)
312
+ db.execute(SYNCHRONOUS_SQL)
313
+ end
314
+
315
+ # Internal method on the tracer pipeline.
316
+ # @api private
317
+ def meta_table_exists?(db)
318
+ row = db.get_first_row(
319
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'meta' LIMIT 1"
320
+ )
321
+ !row.nil?
322
+ end
323
+
324
+ # Internal method on the tracer pipeline.
325
+ # @api private
326
+ def ensure_schema!(db)
327
+ SCHEMA_STATEMENTS.each { |sql| db.execute(sql) }
328
+ end
329
+
330
+ # Internal constant.
331
+ # @api private
332
+ SCHEMA_STATEMENTS = [
333
+ 'CREATE TABLE IF NOT EXISTS meta (' \
334
+ 'schema_version INTEGER NOT NULL, ' \
335
+ 'run_id TEXT NOT NULL, ' \
336
+ 'timestamp TEXT NOT NULL' \
337
+ ')',
338
+ 'CREATE TABLE IF NOT EXISTS examples (' \
339
+ 'example_id TEXT PRIMARY KEY, ' \
340
+ 'metadata_json BLOB NOT NULL' \
341
+ ')',
342
+ 'CREATE TABLE IF NOT EXISTS duplicate_examples (' \
343
+ 'example_id TEXT NOT NULL, ' \
344
+ 'idx INTEGER NOT NULL, ' \
345
+ 'entry_json BLOB NOT NULL, ' \
346
+ 'PRIMARY KEY (example_id, idx)' \
347
+ ')',
348
+ 'CREATE TABLE IF NOT EXISTS all_files (' \
349
+ 'file_name TEXT PRIMARY KEY, ' \
350
+ 'file_path TEXT NOT NULL, ' \
351
+ 'digest TEXT NOT NULL' \
352
+ ')',
353
+ 'CREATE TABLE IF NOT EXISTS dependency (' \
354
+ 'example_id TEXT NOT NULL, ' \
355
+ 'file_name TEXT NOT NULL, ' \
356
+ 'PRIMARY KEY (example_id, file_name)' \
357
+ ')',
358
+ 'CREATE INDEX IF NOT EXISTS idx_dependency_file_name ON dependency(file_name)',
359
+ 'CREATE TABLE IF NOT EXISTS examples_coverage (' \
360
+ 'example_id TEXT NOT NULL, ' \
361
+ 'file_path TEXT NOT NULL, ' \
362
+ 'line_key INTEGER NOT NULL, ' \
363
+ 'strength INTEGER NOT NULL, ' \
364
+ 'PRIMARY KEY (example_id, file_path, line_key)' \
365
+ ')',
366
+ 'CREATE TABLE IF NOT EXISTS env_dependency (' \
367
+ 'example_id TEXT NOT NULL, ' \
368
+ 'env_name TEXT NOT NULL, ' \
369
+ 'PRIMARY KEY (example_id, env_name)' \
370
+ ')',
371
+ 'CREATE TABLE IF NOT EXISTS digest_maps (' \
372
+ 'kind TEXT NOT NULL, ' \
373
+ 'key TEXT NOT NULL, ' \
374
+ 'digest TEXT NOT NULL, ' \
375
+ 'PRIMARY KEY (kind, key)' \
376
+ ')',
377
+ 'CREATE TABLE IF NOT EXISTS id_sets (' \
378
+ 'status TEXT NOT NULL, ' \
379
+ 'example_id TEXT NOT NULL, ' \
380
+ 'PRIMARY KEY (status, example_id)' \
381
+ ')'
382
+ ].freeze
383
+ private_constant :SCHEMA_STATEMENTS
384
+
385
+ # Internal constant.
386
+ # @api private
387
+ TRUNCATE_TABLES = %w[
388
+ meta examples duplicate_examples all_files dependency
389
+ examples_coverage env_dependency digest_maps id_sets
390
+ ].freeze
391
+ private_constant :TRUNCATE_TABLES
392
+
393
+ # Internal method on the tracer pipeline.
394
+ # @api private
395
+ def write_all_tables(db, snapshot, schema_version:)
396
+ TRUNCATE_TABLES.each { |t| db.execute("DELETE FROM #{t}") }
397
+
398
+ db.execute(
399
+ 'INSERT INTO meta (schema_version, run_id, timestamp) VALUES (?, ?, ?)',
400
+ [schema_version, snapshot.run_id, Time.now.utc.iso8601]
401
+ )
402
+
403
+ write_example_rows(db, snapshot)
404
+ write_graph_rows(db, snapshot)
405
+ write_grouped_rows(db, snapshot)
406
+ end
407
+
408
+ # Internal method on the tracer pipeline.
409
+ # @api private
410
+ def write_example_rows(db, snapshot)
411
+ insert_examples(db, snapshot.all_examples || {})
412
+ insert_duplicate_examples(db, snapshot.duplicate_examples || {})
413
+ insert_all_files(db, snapshot.all_files || {})
414
+ end
415
+
416
+ # Internal method on the tracer pipeline.
417
+ # @api private
418
+ def write_graph_rows(db, snapshot)
419
+ insert_dependency(db, snapshot.dependency || {})
420
+ insert_examples_coverage(db, snapshot.examples_coverage || {})
421
+ insert_env_dependency(db, snapshot.env_dependency || {})
422
+ end
423
+
424
+ # Internal method on the tracer pipeline.
425
+ # @api private
426
+ def write_grouped_rows(db, snapshot)
427
+ DIGEST_MAP_KINDS.each do |field, kind|
428
+ insert_digest_map(db, kind, snapshot.send(field) || {})
429
+ end
430
+ STATUS_FIELDS.each do |field, status|
431
+ insert_id_set(db, status, snapshot.send(field) || Set.new)
432
+ end
433
+ end
434
+
435
+ # Internal method on the tracer pipeline.
436
+ # @api private
437
+ def insert_examples(db, examples)
438
+ sql = 'INSERT INTO examples (example_id, metadata_json) VALUES (?, ?)'
439
+ examples.each { |id, meta| db.execute(sql, [id.to_s, ::JSON.generate(meta || {})]) }
440
+ end
441
+
442
+ # Internal method on the tracer pipeline.
443
+ # @api private
444
+ def insert_duplicate_examples(db, dupes)
445
+ sql = 'INSERT INTO duplicate_examples (example_id, idx, entry_json) VALUES (?, ?, ?)'
446
+ dupes.each do |id, list|
447
+ Array(list).each_with_index do |entry, idx|
448
+ db.execute(sql, [id.to_s, idx, ::JSON.generate(entry || {})])
449
+ end
450
+ end
451
+ end
452
+
453
+ # Internal method on the tracer pipeline.
454
+ # @api private
455
+ def insert_all_files(db, all_files)
456
+ sql = 'INSERT INTO all_files (file_name, file_path, digest) VALUES (?, ?, ?)'
457
+ all_files.each do |file_name, meta|
458
+ next unless meta.is_a?(Hash)
459
+
460
+ db.execute(sql, [file_name.to_s, fetch_hash(meta, :file_path).to_s, fetch_hash(meta, :digest).to_s])
461
+ end
462
+ end
463
+
464
+ # Internal method on the tracer pipeline.
465
+ # @api private
466
+ def insert_dependency(db, dependency)
467
+ sql = 'INSERT INTO dependency (example_id, file_name) VALUES (?, ?)'
468
+ dependency.each do |id, file_names|
469
+ Array(file_names).each { |name| db.execute(sql, [id.to_s, name.to_s]) }
470
+ end
471
+ end
472
+
473
+ # Internal method on the tracer pipeline.
474
+ # @api private
475
+ def insert_examples_coverage(db, coverage)
476
+ sql = 'INSERT INTO examples_coverage (example_id, file_path, line_key, strength) VALUES (?, ?, ?, ?)'
477
+ coverage.each do |id, per_file|
478
+ next unless per_file.is_a?(Hash)
479
+
480
+ per_file.each do |file_path, lines|
481
+ iterate_line_entries(lines) do |line_key, strength|
482
+ db.execute(sql, [id.to_s, file_path.to_s, line_key, strength])
483
+ end
484
+ end
485
+ end
486
+ end
487
+
488
+ # Accept both Hash[line_key => strength] (engine output) and
489
+ # Array[strength_or_nil] (raw ::Coverage.result shape) so specs
490
+ # that construct snapshots directly with Array values still
491
+ # round-trip. The canonical on-disk shape is (line_key_int,
492
+ # strength_int) rows; nil strengths drop out.
493
+ def iterate_line_entries(lines)
494
+ case lines
495
+ when Hash
496
+ lines.each { |line_key, strength| yield(line_key.to_i, strength.to_i) if strength }
497
+ when Array
498
+ lines.each_with_index { |strength, idx| yield(idx, strength.to_i) if strength }
499
+ end
500
+ end
501
+
502
+ # Internal method on the tracer pipeline.
503
+ # @api private
504
+ def insert_env_dependency(db, env_dep)
505
+ sql = 'INSERT INTO env_dependency (example_id, env_name) VALUES (?, ?)'
506
+ env_dep.each do |id, names|
507
+ Array(names).each { |name| db.execute(sql, [id.to_s, name.to_s]) }
508
+ end
509
+ end
510
+
511
+ # Internal method on the tracer pipeline.
512
+ # @api private
513
+ def insert_digest_map(db, kind, map)
514
+ sql = 'INSERT INTO digest_maps (kind, key, digest) VALUES (?, ?, ?)'
515
+ map.each { |k, digest| db.execute(sql, [kind, k.to_s, digest.to_s]) }
516
+ end
517
+
518
+ # Internal method on the tracer pipeline.
519
+ # @api private
520
+ def insert_id_set(db, status, ids)
521
+ sql = 'INSERT INTO id_sets (status, example_id) VALUES (?, ?)'
522
+ Array(ids.to_a).each { |id| db.execute(sql, [status, id.to_s]) }
523
+ end
524
+
525
+ # Internal method on the tracer pipeline.
526
+ # @api private
527
+ def dispatch_read(db, field)
528
+ case field
529
+ when :all_examples then read_all_examples(db)
530
+ when :duplicate_examples then read_duplicate_examples(db)
531
+ when :all_files then read_all_files(db)
532
+ when :dependency then read_dependency(db)
533
+ when :reverse_dependency then read_reverse_dependency(db)
534
+ when :examples_coverage then read_examples_coverage(db)
535
+ when :env_dependency then read_env_dependency(db)
536
+ when :cache_hit_reason, :filtered_examples
537
+ # JSON-backend-only surfaces; sqlite no-op. SqliteBackend
538
+ # does not persist these aggregates (would require meta-
539
+ # table columns / schema bump). SqliteBackend also has no
540
+ # parallel-tests peer-merge path (that's JsonBackend-only),
541
+ # so the per-id filtered_examples that backs cache_hit_reason
542
+ # isn't needed either. Empty default mirrors the missing-
543
+ # file-coerces-to-{} contract used by JsonBackend's
544
+ # per-field reads.
545
+ {}
546
+ else dispatch_read_grouped(db, field)
547
+ end
548
+ end
549
+
550
+ # Internal method on the tracer pipeline.
551
+ # @api private
552
+ def dispatch_read_grouped(db, field)
553
+ return read_id_set(db, STATUS_FIELDS.fetch(field)) if STATUS_FIELDS.key?(field)
554
+
555
+ read_digest_map(db, DIGEST_MAP_KINDS.fetch(field))
556
+ end
557
+
558
+ # Internal method on the tracer pipeline.
559
+ # @api private
560
+ def read_all_examples(db)
561
+ result = {}
562
+ db.execute('SELECT example_id, metadata_json FROM examples').each do |row|
563
+ id, meta_json = row
564
+ result[id] = decode_hash_with_sym_keys(meta_json)
565
+ end
566
+ result
567
+ end
568
+
569
+ # Internal method on the tracer pipeline.
570
+ # @api private
571
+ def read_duplicate_examples(db)
572
+ result = Hash.new { |h, k| h[k] = [] }
573
+ rows = db.execute('SELECT example_id, idx, entry_json FROM duplicate_examples ORDER BY example_id, idx')
574
+ rows.each do |row|
575
+ id, _idx, entry_json = row
576
+ result[id] << decode_hash_with_sym_keys(entry_json)
577
+ end
578
+ result.each_with_object({}) { |(k, v), h| h[k] = v } # strip default_proc
579
+ end
580
+
581
+ # Internal method on the tracer pipeline.
582
+ # @api private
583
+ def read_all_files(db)
584
+ result = {}
585
+ db.execute('SELECT file_name, file_path, digest FROM all_files').each do |row|
586
+ name, path, digest = row
587
+ result[name] = { file_name: name, file_path: path, digest: digest }
588
+ end
589
+ result
590
+ end
591
+
592
+ # Internal method on the tracer pipeline.
593
+ # @api private
594
+ def read_dependency(db)
595
+ result = Hash.new { |h, k| h[k] = Set.new }
596
+ db.execute('SELECT example_id, file_name FROM dependency').each do |row|
597
+ id, name = row
598
+ result[id] << name
599
+ end
600
+ result.each_with_object({}) { |(k, v), h| h[k] = v }
601
+ end
602
+
603
+ # Internal method on the tracer pipeline.
604
+ # @api private
605
+ def read_reverse_dependency(db)
606
+ result = Hash.new { |h, k| h[k] = Set.new }
607
+ db.execute('SELECT file_name, example_id FROM dependency').each do |row|
608
+ name, id = row
609
+ result[name] << id
610
+ end
611
+ result.each_with_object({}) { |(k, v), h| h[k] = v }
612
+ end
613
+
614
+ # Internal method on the tracer pipeline.
615
+ # @api private
616
+ def read_examples_coverage(db)
617
+ result = {}
618
+ rows = db.execute('SELECT example_id, file_path, line_key, strength FROM examples_coverage')
619
+ rows.each do |row|
620
+ id, path, line_key, strength = row
621
+ result[id] ||= {}
622
+ (result[id][path] ||= {})[line_key.to_i] = strength.to_i
623
+ end
624
+ result
625
+ end
626
+
627
+ # Internal method on the tracer pipeline.
628
+ # @api private
629
+ def read_env_dependency(db)
630
+ result = Hash.new { |h, k| h[k] = [] }
631
+ rows = db.execute('SELECT example_id, env_name FROM env_dependency ORDER BY example_id, env_name')
632
+ rows.each do |row|
633
+ id, name = row
634
+ result[id] << name
635
+ end
636
+ result.each_with_object({}) { |(k, v), h| h[k] = v }
637
+ end
638
+
639
+ # Internal method on the tracer pipeline.
640
+ # @api private
641
+ def read_id_set(db, status)
642
+ result = Set.new
643
+ db.execute('SELECT example_id FROM id_sets WHERE status = ?', [status]).each do |row|
644
+ result << row.first
645
+ end
646
+ result
647
+ end
648
+
649
+ # Internal method on the tracer pipeline.
650
+ # @api private
651
+ def read_digest_map(db, kind)
652
+ result = {}
653
+ db.execute('SELECT key, digest FROM digest_maps WHERE kind = ?', [kind]).each do |row|
654
+ key, digest = row
655
+ result[key] = digest
656
+ end
657
+ result
658
+ end
659
+
660
+ # Internal method on the tracer pipeline.
661
+ # @api private
662
+ def empty_default_for(field)
663
+ return Set.new if STATUS_FIELDS.key?(field)
664
+
665
+ {}
666
+ end
667
+
668
+ # Internal method on the tracer pipeline.
669
+ # @api private
670
+ def decode_hash_with_sym_keys(json_bytes)
671
+ parsed = ::JSON.parse(json_bytes)
672
+ return parsed unless parsed.is_a?(Hash)
673
+
674
+ parsed.transform_keys(&:to_sym)
675
+ end
676
+
677
+ # Internal method on the tracer pipeline.
678
+ # @api private
679
+ def fetch_hash(hash, key)
680
+ return hash[key] if hash.key?(key)
681
+
682
+ hash[key.to_s]
683
+ end
684
+
685
+ # Internal method on the tracer pipeline.
686
+ # @api private
687
+ def info(message)
688
+ @logger&.info(message)
689
+ end
690
+ end
691
+ # rubocop:enable Metrics/ClassLength
692
+ end
693
+ end