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,884 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest/md5'
4
+ require 'fileutils'
5
+ require 'json'
6
+ require 'set'
7
+ require 'time' # Time#iso8601 for last_run.json timestamp
8
+
9
+ require_relative 'backend'
10
+ require_relative 'lazy_snapshot'
11
+ require_relative 'schema'
12
+ require_relative 'serializer/json'
13
+ require_relative 'serializer/msgpack'
14
+ require_relative 'snapshot'
15
+
16
+ module RSpecTracer
17
+ # Internal Storage — see {RSpecTracer} for the user-facing surface.
18
+ # @api private
19
+ module Storage
20
+ # JSON-on-disk storage backend. 1.x shipped this layout without a
21
+ # formal contract; 2.0 treats the FILENAMES list below as the
22
+ # authoritative user-facing surface.
23
+ #
24
+ # External tooling (CI cache keys, debug scripts, report
25
+ # renderers) may reference these exact filenames, so additions or
26
+ # removals are breaking changes. The shared-examples contract in
27
+ # `spec/contracts/storage_backend.rb` enforces the list.
28
+ #
29
+ # Commit point: `last_run.json` is written last via tmp + rename.
30
+ # If any of the 11 per-run files fails to write, `last_run.json`
31
+ # stays pointed at the previous successful run and the partially-
32
+ # written run-id directory is orphaned (harmless; `clear!` reaps
33
+ # it). Readers that see `last_run.json` therefore see a complete
34
+ # snapshot.
35
+ #
36
+ # Concurrency: an exclusive flock on a sentinel file
37
+ # (`.rspec_tracer.lock` under cache_path) serializes writers.
38
+ # Readers do not take the lock - `last_run.json`'s atomic rename
39
+ # is their consistency model.
40
+ #
41
+ # Corruption policy: `load_graph` never raises. Missing files,
42
+ # malformed JSON, wrong schema, binary-garbage input all yield
43
+ # `nil` + an info log. This is the invariant the fuzz spec
44
+ # asserts across 1000 iterations.
45
+ #
46
+ # Encoding: every read and write passes `encoding: 'UTF-8'`.
47
+ # Fixes the `Encoding::InvalidByteSequenceError` that bit the
48
+ # dogfood path when an example title contained a non-ASCII byte on
49
+ # a US-ASCII-defaulted filesystem.
50
+ # rubocop:disable Metrics/ClassLength
51
+ class JsonBackend
52
+ # On-disk filenames under the default `:json` serializer. This
53
+ # is the user-facing surface documented in
54
+ # USER_FACING_SURFACE.md section 6 - external tooling that walks
55
+ # `rspec_tracer_cache/` relies on exactly these names.
56
+ # The `:msgpack` serializer substitutes `.msgpack.gz` for the
57
+ # `.json` suffix (one file per field on disk); the file stems
58
+ # and per-field semantics do not change.
59
+ # boot_set.json lands at the end of the list - additive w.r.t.
60
+ # 1.x and v2 readers that walked this enumeration. It carries
61
+ # the project's transitive boot-load set (schema_version 3).
62
+ # wsi_snapshot.json persists the WholeSuiteInvalidators
63
+ # digest_snapshot so warm runs can tell whether Gemfile.lock /
64
+ # .ruby-version / .rspec-tracer / tracer-gem identity changed
65
+ # since the previous run. Without it, warm runs always saw a
66
+ # nil previous and treated every run as a cold first run.
67
+ # Missing file deserializes to `{}` so older caches still load -
68
+ # the fallback path fires one full re-run (safe).
69
+ # env_snapshot.json persists the `Tracker::EnvSnapshot` digest
70
+ # map for env-var values the per-example `tracks: { env: ... }`
71
+ # DSL declares. Same missing-coerces-to-`{}` fallback as
72
+ # wsi_snapshot - no schema bump.
73
+ # env_dependency.json persists the per-example tracked-env
74
+ # attribution map that reporters need for the Examples Dependency
75
+ # report. Missing file coerces to `{}`; older caches load
76
+ # without a cold re-run.
77
+ FILENAMES = %w[
78
+ all_examples.json
79
+ duplicate_examples.json
80
+ interrupted_examples.json
81
+ flaky_examples.json
82
+ failed_examples.json
83
+ pending_examples.json
84
+ skipped_examples.json
85
+ all_files.json
86
+ dependency.json
87
+ reverse_dependency.json
88
+ examples_coverage.json
89
+ boot_set.json
90
+ wsi_snapshot.json
91
+ env_snapshot.json
92
+ env_dependency.json
93
+ cache_hit_reason.json
94
+ filtered_examples.json
95
+ ].freeze
96
+
97
+ # Internal constant.
98
+ # @api private
99
+ LAST_RUN_FILENAME = 'last_run.json'
100
+ # Internal constant.
101
+ # @api private
102
+ LOCK_FILENAME = '.rspec_tracer.lock'
103
+ # Internal constant.
104
+ # @api private
105
+ ENCODING = 'UTF-8'
106
+
107
+ # Known snapshot field symbols. Derived directly from FIELD_KINDS
108
+ # below (the write-side and read-side shape tables both enumerate
109
+ # the same set, so a divergence would already blow up write
110
+ # paths). Kept as an Array of Symbol so `#read_field` can dispatch
111
+ # without constructing a per-serializer filename table; the
112
+ # filename is computed as "#{field}.#{@serializer.extension}".
113
+ FIELD_NAMES = %i[
114
+ all_examples
115
+ duplicate_examples
116
+ interrupted_examples
117
+ flaky_examples
118
+ failed_examples
119
+ pending_examples
120
+ skipped_examples
121
+ all_files
122
+ dependency
123
+ reverse_dependency
124
+ examples_coverage
125
+ boot_set
126
+ wsi_snapshot
127
+ env_snapshot
128
+ env_dependency
129
+ cache_hit_reason
130
+ filtered_examples
131
+ ].freeze
132
+
133
+ # Binds a backend + run directory so `LazySnapshot` readers
134
+ # call exactly one public entry point (`backend.read_field`).
135
+ # Keeping this as a nested class (not a Proc) so mutant can
136
+ # introspect the reader contract.
137
+ class FieldReader
138
+ # Internal method on the tracer pipeline.
139
+ # @api private
140
+ def initialize(backend:, dir:)
141
+ @backend = backend
142
+ @dir = dir
143
+ end
144
+
145
+ # Internal method on the tracer pipeline.
146
+ # @api private
147
+ def read(field)
148
+ @backend.read_field(@dir, field)
149
+ end
150
+ end
151
+
152
+ # Write-side field groups. Each group dispatches to one
153
+ # serializer (Hash pass-through, Set->sorted Array, or the
154
+ # Hash[id => Set<path>] -> Hash[id => Array<path>] flavor
155
+ # shared by dependency + reverse_dependency). Kept data-driven
156
+ # so a schema_version bump adds one entry instead of a new
157
+ # branch. Read-side uses FIELD_KINDS below.
158
+ ID_SET_FIELDS = %w[
159
+ interrupted_examples flaky_examples failed_examples pending_examples skipped_examples
160
+ ].freeze
161
+ # Internal constant.
162
+ # @api private
163
+ HASH_FIELDS = %w[
164
+ all_examples duplicate_examples all_files examples_coverage
165
+ boot_set wsi_snapshot env_snapshot env_dependency cache_hit_reason
166
+ filtered_examples
167
+ ].freeze
168
+ # Internal constant.
169
+ # @api private
170
+ DEPENDENCY_FIELDS = %w[dependency reverse_dependency].freeze
171
+
172
+ # Read-side field -> deserializer-kind map. Drives
173
+ # `decode_field` so the lazy reader looks up one shape
174
+ # per field instead of spelling out a case/when that
175
+ # has to stay in sync with FILENAMES. `:symbolized` =
176
+ # Hash whose inner Hash values get symbolized keys
177
+ # (1.x's all_examples / all_files convention);
178
+ # `:dupe_examples` = same but Array-of-inner-Hash;
179
+ # `:id_set` = Array on disk -> Set in memory;
180
+ # `:dependency` = Hash[id => Array] -> Hash[id => Set];
181
+ # `:plain_hash` = pass-through (examples_coverage, the
182
+ # digest maps, env_dependency).
183
+ FIELD_KINDS = {
184
+ all_examples: :symbolized,
185
+ all_files: :symbolized,
186
+ duplicate_examples: :dupe_examples,
187
+ interrupted_examples: :id_set,
188
+ flaky_examples: :id_set,
189
+ failed_examples: :id_set,
190
+ pending_examples: :id_set,
191
+ skipped_examples: :id_set,
192
+ dependency: :dependency,
193
+ reverse_dependency: :dependency,
194
+ examples_coverage: :plain_hash,
195
+ boot_set: :plain_hash,
196
+ wsi_snapshot: :plain_hash,
197
+ env_snapshot: :plain_hash,
198
+ env_dependency: :plain_hash,
199
+ cache_hit_reason: :plain_hash,
200
+ filtered_examples: :plain_hash
201
+ }.freeze
202
+
203
+ # Internal attribute.
204
+ # @api private
205
+ attr_reader :cache_path, :serializer, :serializer_name
206
+
207
+ # rubocop:disable Metrics/ParameterLists
208
+ def initialize(cache_path:, logger: nil, retention_local_count: nil,
209
+ warn_per_file_mb: nil, warn_total_mb: nil, serializer: :json)
210
+ # rubocop:enable Metrics/ParameterLists
211
+ @cache_path = File.expand_path(cache_path)
212
+ @logger = logger
213
+ @retention_local_count = retention_local_count
214
+ @warn_per_file_mb = warn_per_file_mb
215
+ @warn_total_mb = warn_total_mb
216
+ @serializer = resolve_serializer(serializer)
217
+ @serializer_name = serializer
218
+ end
219
+
220
+ # Internal method on the tracer pipeline.
221
+ # @api private
222
+ def last_run_id
223
+ manifest = read_last_run_manifest
224
+ return nil unless manifest.is_a?(Hash)
225
+
226
+ run_id = manifest['run_id']
227
+ return nil if run_id.nil? || run_id.to_s.empty?
228
+
229
+ run_id
230
+ end
231
+
232
+ # Internal method on the tracer pipeline.
233
+ # @api private
234
+ def load_graph(schema_version:)
235
+ manifest = read_last_run_manifest
236
+ return nil unless manifest.is_a?(Hash)
237
+
238
+ stored = manifest['schema_version']
239
+ unless Schema.supported?(stored) && stored == schema_version
240
+ info("schema_version mismatch (stored=#{stored.inspect}, expected=#{schema_version}); cold run")
241
+ return nil
242
+ end
243
+
244
+ run_id = manifest['run_id']
245
+ return nil if run_id.nil? || run_id.to_s.empty?
246
+
247
+ dir = File.join(@cache_path, run_id)
248
+ return nil unless File.directory?(dir)
249
+
250
+ LazySnapshot.new(
251
+ schema_version: stored, run_id: run_id,
252
+ reader: FieldReader.new(backend: self, dir: dir)
253
+ )
254
+ rescue StandardError => e
255
+ info("failed to load cache: #{e.class}: #{e.message}; cold run")
256
+ nil
257
+ end
258
+
259
+ # Read and deserialize one per-run field. Public so
260
+ # `FieldReader` (constructed by `load_graph`) can dispatch.
261
+ # Missing file -> same default value the eager read previously
262
+ # produced (Set.new for ID-set fields, {} for hashes) -
263
+ # preserves the "malformed cache loads gracefully" contract.
264
+ #
265
+ # `deep_intern` runs before the decode so String dedup
266
+ # happens once per on-disk path / example_id regardless of
267
+ # how many times the value appears in the parsed tree.
268
+ # RAM win on large caches is the whole point of this method;
269
+ # see json_backend_spec.rb "string interning" for the
270
+ # measurable assertion.
271
+ def read_field(dir, field)
272
+ raise ArgumentError, "unknown snapshot field: #{field.inspect}" unless FIELD_KINDS.key?(field)
273
+
274
+ raw = read_run_file(dir, field_filename(field))
275
+ decode_field(field, deep_intern(raw))
276
+ end
277
+
278
+ # Per-serializer on-disk filename for a snapshot field.
279
+ # `:json` -> `all_examples.json`; `:msgpack` ->
280
+ # `all_examples.msgpack.gz`. Public so integration specs /
281
+ # reporters can resolve the expected path without reaching
282
+ # into @serializer.
283
+ def field_filename(field)
284
+ "#{field}.#{@serializer.extension}"
285
+ end
286
+
287
+ # Internal method on the tracer pipeline.
288
+ # @api private
289
+ def save_graph(snapshot, schema_version:)
290
+ raise ArgumentError, 'snapshot must not be nil' if snapshot.nil?
291
+
292
+ unless Schema.supported?(schema_version)
293
+ raise ArgumentError, "unsupported schema_version: #{schema_version.inspect}"
294
+ end
295
+
296
+ run_id = snapshot.run_id
297
+ raise ArgumentError, 'snapshot.run_id must be a non-empty string' if run_id.nil? || run_id.to_s.empty?
298
+
299
+ transactional_save do
300
+ dir = File.join(@cache_path, run_id)
301
+ FileUtils.mkdir_p(dir)
302
+ write_run_files(dir, snapshot)
303
+ write_last_run_atomic(schema_version: schema_version, run_id: run_id)
304
+ end
305
+
306
+ maybe_prune_after_save
307
+ maybe_warn_size_budget(run_id)
308
+ snapshot
309
+ end
310
+
311
+ # Retain the `keep` most-recently-modified run-id directories
312
+ # under cache_path and delete older ones. Always preserves the
313
+ # run-id that `last_run.json` points at (deleting it would make
314
+ # the next reader cold-run). Returns the count removed. Never
315
+ # raises - a prune failure is logged at warn level and treated
316
+ # as best-effort cleanup, same graceful-degradation contract
317
+ # the remote cache backends use.
318
+ #
319
+ # `keep` nil / non-positive -> no-op. Called automatically from
320
+ # `save_graph` when the backend was constructed with
321
+ # `retention_local_count:`; also exposed via `rake
322
+ # rspec_tracer:cache:gc` for one-off cleanup.
323
+ def prune_run_dirs!(keep:)
324
+ return 0 if keep.nil? || keep <= 0
325
+ return 0 unless File.directory?(@cache_path)
326
+
327
+ current = last_run_id
328
+ candidates = collect_run_dirs
329
+ return 0 if candidates.empty?
330
+
331
+ _keep, pruned = partition_dirs_to_prune(candidates, keep: keep, current: current)
332
+ pruned.each { |path| FileUtils.rm_rf(path) }
333
+ pruned.size
334
+ rescue StandardError => e
335
+ @logger&.warn("rspec-tracer cache gc: prune failed (#{e.class}: #{e.message})")
336
+ 0
337
+ end
338
+
339
+ # Internal method on the tracer pipeline.
340
+ # @api private
341
+ def transactional_save(&block)
342
+ raise ArgumentError, 'block required' unless block
343
+
344
+ FileUtils.mkdir_p(@cache_path)
345
+ File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |lock|
346
+ lock.flock(File::LOCK_EX)
347
+ yield
348
+ end
349
+ end
350
+
351
+ # Internal method on the tracer pipeline.
352
+ # @api private
353
+ def clear!
354
+ return unless File.directory?(@cache_path)
355
+
356
+ FileUtils.rm_rf(@cache_path)
357
+ end
358
+
359
+ # Merge per-worker snapshots (written to `peer_cache_paths`) into
360
+ # this backend's top-level cache and persist via `save_graph`.
361
+ # Read each peer via `load_graph` so schema + corruption policy
362
+ # (missing files yield nil, malformed JSON logs + returns nil)
363
+ # flows through the same path as a normal load.
364
+ #
365
+ # No peers / every peer nil -> no-op returns nil. Partial peers
366
+ # merge what's available; graceful degradation is the entire
367
+ # point of running this at at_exit time.
368
+ #
369
+ # `schema_version` is passed through so peers saved under a
370
+ # different schema version are rejected without side effects
371
+ # (same semantics as a warm run under a mismatched cache).
372
+ def merge_from_peers(peer_cache_paths, schema_version:)
373
+ peer_snapshots = peer_cache_paths.filter_map do |path|
374
+ self.class.new(cache_path: path, logger: @logger, serializer: @serializer_name)
375
+ .load_graph(schema_version: schema_version)
376
+ end
377
+
378
+ return nil if peer_snapshots.empty?
379
+
380
+ merged = Merger.call(peer_snapshots, schema_version: schema_version)
381
+ save_graph(merged, schema_version: schema_version)
382
+ merged
383
+ end
384
+
385
+ # Stateless snapshot union. parallel_tests partitions spec files
386
+ # across workers, so example IDs are disjoint in practice - the
387
+ # merge collision rules (first-wins for metadata, sum-of-ints for
388
+ # per-line coverage) only fire on collaborating workers that
389
+ # happened to observe the same input file.
390
+ module Merger
391
+ # Internal helper for the tracer pipeline.
392
+ # @api private
393
+ def self.call(snapshots, schema_version:)
394
+ state = empty_state
395
+ snapshots.each { |s| absorb(state, s) }
396
+
397
+ state[:reverse_dependency] = reverse_of(state[:dependency])
398
+ state[:run_id] = Digest::MD5.hexdigest(state[:all_examples].keys.sort.to_json)
399
+ state[:cache_hit_reason] = state[:filtered_examples].values.tally
400
+
401
+ build_merged_snapshot(state, schema_version: schema_version)
402
+ end
403
+
404
+ # Internal helper for the tracer pipeline.
405
+ # @api private
406
+ def self.build_merged_snapshot(state, schema_version:)
407
+ Snapshot.new(
408
+ schema_version: schema_version,
409
+ run_id: state[:run_id],
410
+ all_examples: state[:all_examples],
411
+ duplicate_examples: state[:duplicate_examples],
412
+ interrupted_examples: state[:interrupted_examples],
413
+ flaky_examples: state[:flaky_examples],
414
+ failed_examples: state[:failed_examples],
415
+ pending_examples: state[:pending_examples],
416
+ skipped_examples: state[:skipped_examples],
417
+ all_files: state[:all_files],
418
+ dependency: state[:dependency],
419
+ reverse_dependency: state[:reverse_dependency],
420
+ examples_coverage: state[:examples_coverage],
421
+ boot_set: state[:boot_set],
422
+ wsi_snapshot: state[:wsi_snapshot],
423
+ env_snapshot: state[:env_snapshot],
424
+ env_dependency: state[:env_dependency],
425
+ cache_hit_reason: state[:cache_hit_reason],
426
+ filtered_examples: state[:filtered_examples]
427
+ )
428
+ end
429
+
430
+ # Internal helper for the tracer pipeline.
431
+ # @api private
432
+ def self.empty_state
433
+ {
434
+ all_examples: {},
435
+ duplicate_examples: Hash.new { |h, k| h[k] = [] },
436
+ interrupted_examples: Set.new,
437
+ flaky_examples: Set.new,
438
+ failed_examples: Set.new,
439
+ pending_examples: Set.new,
440
+ skipped_examples: Set.new,
441
+ all_files: {},
442
+ dependency: Hash.new { |h, k| h[k] = Set.new },
443
+ examples_coverage: {},
444
+ boot_set: {},
445
+ wsi_snapshot: {},
446
+ env_snapshot: {},
447
+ env_dependency: {},
448
+ filtered_examples: {}
449
+ }
450
+ end
451
+
452
+ # Union every field from one peer snapshot into the running
453
+ # state. Each field has a distinct combine rule (merge-first-wins,
454
+ # Set#merge, concat, or summing coverage strengths), so the
455
+ # branching is inherent to the shape. Decomposing per-field would
456
+ # scatter the merge contract.
457
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
458
+ def self.absorb(state, snapshot)
459
+ state[:all_examples].merge!(snapshot.all_examples || {}) { |_, v, _| v }
460
+ (snapshot.duplicate_examples || {}).each do |id, entries|
461
+ state[:duplicate_examples][id].concat(entries)
462
+ end
463
+ state[:interrupted_examples].merge(snapshot.interrupted_examples || Set.new)
464
+ state[:flaky_examples].merge(snapshot.flaky_examples || Set.new)
465
+ state[:failed_examples].merge(snapshot.failed_examples || Set.new)
466
+ state[:pending_examples].merge(snapshot.pending_examples || Set.new)
467
+ state[:skipped_examples].merge(snapshot.skipped_examples || Set.new)
468
+ state[:all_files].merge!(snapshot.all_files || {}) { |_, v, _| v }
469
+ (snapshot.dependency || {}).each do |id, paths|
470
+ state[:dependency][id].merge(paths)
471
+ end
472
+ merge_examples_coverage!(state[:examples_coverage], snapshot.examples_coverage || {})
473
+ state[:boot_set].merge!(snapshot.boot_set || {})
474
+ state[:wsi_snapshot].merge!(snapshot.wsi_snapshot || {})
475
+ state[:env_snapshot].merge!(snapshot.env_snapshot || {})
476
+ merge_env_dependency!(state[:env_dependency], snapshot.env_dependency || {})
477
+ merge_filtered_examples!(state[:filtered_examples], snapshot.filtered_examples || {})
478
+ end
479
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
480
+
481
+ # Per-example env attribution unions set-wise: an example that
482
+ # declared `tracks: { env: [A, B] }` on one worker and
483
+ # `tracks: { env: [B, C] }` on another (edge case; parallel_tests
484
+ # workers rarely run the same example) collapses to [A, B, C].
485
+ def self.merge_env_dependency!(target, source)
486
+ source.each do |id, names|
487
+ existing = target[id] || []
488
+ target[id] = (existing | Array(names)).sort
489
+ end
490
+ end
491
+
492
+ # Merge per-worker filtered_examples by example_id. Every
493
+ # parallel_tests worker independently runs Filter.select
494
+ # against the same global previous-run snapshot
495
+ # (Engine#compute_filter_decisions walks the registry seeded
496
+ # from `prev` and the filter intersects against
497
+ # `prev.all_examples.keys`), so every worker's filtered_examples
498
+ # hash is IDENTICAL. First-write-wins collapses the duplicate
499
+ # ids; `Merger.call` re-derives `cache_hit_reason` as the
500
+ # values-tally of the merged hash so the merged
501
+ # cache_hit_reason is the per-id-correct count (not the
502
+ # N-fold-inflated sum of identical per-worker tallies that
503
+ # the pre-#193 merge produced).
504
+ def self.merge_filtered_examples!(target, source)
505
+ source.each { |id, reason| target[id] ||= reason }
506
+ end
507
+
508
+ # Internal helper for the tracer pipeline.
509
+ # @api private
510
+ def self.merge_examples_coverage!(target, source)
511
+ source.each do |id, per_file|
512
+ entry = target[id] ||= {}
513
+ per_file.each do |file_path, lines|
514
+ file_entry = entry[file_path] ||= {}
515
+ lines.each do |line_key, strength|
516
+ file_entry[line_key] = (file_entry[line_key] || 0) + (strength || 0)
517
+ end
518
+ end
519
+ end
520
+ end
521
+
522
+ # Internal helper for the tracer pipeline.
523
+ # @api private
524
+ def self.reverse_of(dependency)
525
+ reverse = Hash.new { |h, k| h[k] = Set.new }
526
+ dependency.each do |id, file_names|
527
+ file_names.each { |name| reverse[name] << id }
528
+ end
529
+ reverse
530
+ end
531
+ end
532
+
533
+ private
534
+
535
+ # Internal method on the tracer pipeline.
536
+ # @api private
537
+ def last_run_path
538
+ File.join(@cache_path, LAST_RUN_FILENAME)
539
+ end
540
+
541
+ # Internal method on the tracer pipeline.
542
+ # @api private
543
+ def lock_path
544
+ File.join(@cache_path, LOCK_FILENAME)
545
+ end
546
+
547
+ # Internal method on the tracer pipeline.
548
+ # @api private
549
+ def maybe_prune_after_save
550
+ prune_run_dirs!(keep: @retention_local_count) if @retention_local_count
551
+ end
552
+
553
+ # Internal constant.
554
+ # @api private
555
+ BYTES_PER_MB = 1_048_576
556
+ private_constant :BYTES_PER_MB
557
+
558
+ # Emit a warn-level log line for each just-saved file that
559
+ # exceeded the per-file budget, and one total-budget line when
560
+ # the whole cache tree exceeds that threshold. Both thresholds
561
+ # are MiB; 0 / nil disables. The warning suggests the most
562
+ # effective remediations in order: add_filter for vendor paths
563
+ # (usually the biggest win), transitive_load_tracking off
564
+ # (cuts the constants-blind-spot overhead), and the `:msgpack`
565
+ # serializer. Budgets surface size-blow-up symptoms (issues
566
+ # #15 / #20) without forcing behavior change.
567
+ def maybe_warn_size_budget(run_id)
568
+ warn_oversized_run_files(run_id) if positive_threshold?(@warn_per_file_mb)
569
+ warn_oversized_cache_total if positive_threshold?(@warn_total_mb)
570
+ end
571
+
572
+ # Internal method on the tracer pipeline.
573
+ # @api private
574
+ def positive_threshold?(value)
575
+ value.is_a?(::Integer) && value.positive?
576
+ end
577
+
578
+ # Internal method on the tracer pipeline.
579
+ # @api private
580
+ def warn_oversized_run_files(run_id)
581
+ run_dir = File.join(@cache_path, run_id)
582
+ return unless File.directory?(run_dir)
583
+
584
+ threshold_bytes = @warn_per_file_mb * BYTES_PER_MB
585
+ per_file_glob(run_dir).each do |path|
586
+ size = File.size(path)
587
+ next unless size > threshold_bytes
588
+
589
+ @logger&.warn(
590
+ "rspec-tracer cache: #{File.basename(path)} is #{format_mib(size)} " \
591
+ "(> #{@warn_per_file_mb} MiB per-file threshold); remediations (in order): " \
592
+ 'add_filter for vendor paths, `transitive_load_tracking false`, ' \
593
+ '`storage_backend :json, serializer: :msgpack` for disk reduction'
594
+ )
595
+ end
596
+ end
597
+
598
+ # Internal method on the tracer pipeline.
599
+ # @api private
600
+ def warn_oversized_cache_total
601
+ total = total_cache_size_bytes
602
+ threshold_bytes = @warn_total_mb * BYTES_PER_MB
603
+ return unless total > threshold_bytes
604
+
605
+ @logger&.warn(
606
+ "rspec-tracer cache: total size is #{format_mib(total)} " \
607
+ "(> #{@warn_total_mb} MiB total threshold); remediations (in order): " \
608
+ '`cache_retention_local_count N` to cap history, ' \
609
+ 'add_filter for vendor paths, ' \
610
+ '`storage_backend :json, serializer: :msgpack` for disk reduction'
611
+ )
612
+ end
613
+
614
+ # Glob matching this backend's serializer extension. Surfaces
615
+ # the active on-disk layout so size-budget warnings stay
616
+ # accurate when the user switches to `:msgpack` (.msgpack.gz
617
+ # files instead of .json).
618
+ def per_file_glob(run_dir)
619
+ Dir[File.join(run_dir, "*.#{@serializer.extension}")]
620
+ end
621
+
622
+ # Internal method on the tracer pipeline.
623
+ # @api private
624
+ def total_cache_size_bytes
625
+ total = 0
626
+ Dir[File.join(@cache_path, '**', "*.#{@serializer.extension}")].each do |path|
627
+ total += File.size(path) if File.file?(path)
628
+ end
629
+ total
630
+ rescue StandardError
631
+ 0
632
+ end
633
+
634
+ # Internal method on the tracer pipeline.
635
+ # @api private
636
+ def format_mib(bytes)
637
+ "#{(bytes.to_f / BYTES_PER_MB).round(1)} MiB"
638
+ end
639
+
640
+ # Enumerate run-id subdirectories of cache_path, newest first
641
+ # by mtime. Non-directory children (last_run.json, lock file)
642
+ # and dotfiles are excluded so a stray `.DS_Store` or editor
643
+ # swap file doesn't confuse the prune math.
644
+ def collect_run_dirs
645
+ entries = Dir.children(@cache_path).filter_map do |name|
646
+ next if name.start_with?('.')
647
+
648
+ path = File.join(@cache_path, name)
649
+ next unless File.directory?(path)
650
+
651
+ [path, File.mtime(path).to_f]
652
+ end
653
+ entries.sort_by { |(_, mtime)| -mtime }.map(&:first)
654
+ end
655
+
656
+ # Split a newest-first list of run directories into (keep,
657
+ # prune). The live `current` run-id is always retained even if
658
+ # it fell off the top-N by mtime (defensive: an external
659
+ # `touch` on an old dir must not force deletion of the live
660
+ # one). Inputs are absolute paths; `current` is the basename
661
+ # reported by `last_run_id` (may be nil if last_run.json is
662
+ # missing or corrupt).
663
+ def partition_dirs_to_prune(candidates, keep:, current:)
664
+ keep_paths = []
665
+ prune_paths = []
666
+ candidates.each do |path|
667
+ if keep_paths.size < keep || File.basename(path) == current
668
+ keep_paths << path
669
+ else
670
+ prune_paths << path
671
+ end
672
+ end
673
+ [keep_paths, prune_paths]
674
+ end
675
+
676
+ # Internal method on the tracer pipeline.
677
+ # @api private
678
+ def read_last_run_manifest
679
+ return nil unless File.file?(last_run_path)
680
+
681
+ read_json(last_run_path)
682
+ rescue StandardError
683
+ nil
684
+ end
685
+
686
+ # last_run.json is always plain JSON regardless of serializer -
687
+ # it is the human-debuggable + CI-script-compatible pointer that
688
+ # USER_FACING_SURFACE.md section 6 locks. Helper stays narrow so the
689
+ # serializer dispatch can not accidentally reach it.
690
+ def read_json(path)
691
+ contents = File.read(path, encoding: ENCODING)
692
+ JSON.parse(contents)
693
+ end
694
+
695
+ # Internal method on the tracer pipeline.
696
+ # @api private
697
+ def write_json_atomic(path, data)
698
+ tmp_path = "#{path}.tmp.#{Process.pid}.#{rand(1_000_000)}"
699
+ File.write(tmp_path, JSON.pretty_generate(data), encoding: ENCODING)
700
+ File.rename(tmp_path, path)
701
+ ensure
702
+ File.delete(tmp_path) if tmp_path && File.file?(tmp_path)
703
+ end
704
+
705
+ # Internal method on the tracer pipeline.
706
+ # @api private
707
+ def write_run_files(dir, snapshot)
708
+ HASH_FIELDS.each { |f| write_run_field(dir, f, snapshot.send(f) || {}) }
709
+ ID_SET_FIELDS.each { |f| write_run_field(dir, f, serialize_id_set(snapshot.send(f))) }
710
+ DEPENDENCY_FIELDS.each { |f| write_run_field(dir, f, serialize_dependency(snapshot.send(f))) }
711
+ end
712
+
713
+ # Internal method on the tracer pipeline.
714
+ # @api private
715
+ def write_run_field(dir, name, payload)
716
+ write_payload_atomic(File.join(dir, field_filename(name.to_sym)), payload)
717
+ end
718
+
719
+ # Atomic write for a per-field payload. Encodes via the active
720
+ # serializer; writes binary so msgpack + zlib bytes are not
721
+ # re-encoded by Ruby's IO layer. Same tmp-rename pattern as
722
+ # write_json_atomic (last_run.json commit point).
723
+ def write_payload_atomic(path, data)
724
+ tmp_path = "#{path}.tmp.#{Process.pid}.#{rand(1_000_000)}"
725
+ File.binwrite(tmp_path, @serializer.encode(data))
726
+ File.rename(tmp_path, path)
727
+ ensure
728
+ File.delete(tmp_path) if tmp_path && File.file?(tmp_path)
729
+ end
730
+
731
+ # Internal method on the tracer pipeline.
732
+ # @api private
733
+ def write_last_run_atomic(schema_version:, run_id:)
734
+ manifest = { 'schema_version' => schema_version, 'run_id' => run_id, 'timestamp' => Time.now.utc.iso8601 }
735
+ write_json_atomic(last_run_path, manifest)
736
+ end
737
+
738
+ # Dispatch one field's raw JSON body through the right
739
+ # deserializer. Paired with FIELD_KINDS. Kept here rather
740
+ # than on the reader so all shape knowledge stays in one
741
+ # class; mutant sees one AST node per kind branch.
742
+ def decode_field(field, raw)
743
+ case FIELD_KINDS.fetch(field)
744
+ when :symbolized then deserialize_symbolized(raw)
745
+ when :dupe_examples then deserialize_dupe_examples(raw)
746
+ when :id_set then deserialize_id_set(raw)
747
+ when :dependency then deserialize_dependency(raw)
748
+ when :plain_hash then deserialize_plain_hash(raw)
749
+ end
750
+ end
751
+
752
+ # Plain-hash round-trip: JSON.parse returns nil on failure, and
753
+ # a valid-but-wrong-shape file would otherwise poison the
754
+ # Snapshot field. Treat anything non-Hash as the empty default.
755
+ def deserialize_plain_hash(raw)
756
+ raw.is_a?(Hash) ? raw : {}
757
+ end
758
+
759
+ # Walk a parsed JSON tree and replace every String with its
760
+ # frozen-string-table entry via `String#-@`. Idempotent on
761
+ # already-frozen Strings. Portable across every matrix Ruby
762
+ # (json-gem's `freeze: true` option only arrived in 2.8 /
763
+ # Ruby 3.4); the explicit walk keeps behavior identical on
764
+ # Ruby 3.1 + 3.2 cells.
765
+ #
766
+ # Big win on dependency.json where the same file path repeats
767
+ # across every example that depends on it - 2000 unique paths
768
+ # may appear 1M+ times; interning collapses that to 2000
769
+ # objects + refs. Small overhead on fields where strings are
770
+ # unique (description text in all_examples) but the RAM
771
+ # savings on the path-heavy fields dominate.
772
+ def deep_intern(obj)
773
+ case obj
774
+ when Hash
775
+ obj.each_with_object({}) { |(k, v), h| h[k.is_a?(String) ? -k : k] = deep_intern(v) }
776
+ when Array
777
+ obj.map { |v| deep_intern(v) }
778
+ when String
779
+ -obj
780
+ else
781
+ obj
782
+ end
783
+ end
784
+
785
+ # Internal method on the tracer pipeline.
786
+ # @api private
787
+ def read_run_file(dir, name)
788
+ path = File.join(dir, name)
789
+ return nil unless File.file?(path)
790
+
791
+ @serializer.decode(File.binread(path))
792
+ rescue StandardError
793
+ nil
794
+ end
795
+
796
+ # Sorted Array on disk, Set in memory - matches 1.x report_writer.
797
+ def serialize_id_set(collection)
798
+ return [] if collection.nil?
799
+
800
+ collection.to_a.sort
801
+ end
802
+
803
+ # Internal method on the tracer pipeline.
804
+ # @api private
805
+ def deserialize_id_set(raw)
806
+ return Set.new unless raw.is_a?(Array)
807
+
808
+ raw.to_set
809
+ end
810
+
811
+ # dependency / reverse_dependency: Hash[id => Set<path>] in
812
+ # memory, Hash[id => Array<path>] on disk.
813
+ def serialize_dependency(collection)
814
+ return {} if collection.nil?
815
+
816
+ collection.transform_values { |paths| Array(paths) }
817
+ end
818
+
819
+ # Internal method on the tracer pipeline.
820
+ # @api private
821
+ def deserialize_dependency(raw)
822
+ return {} unless raw.is_a?(Hash)
823
+
824
+ raw.transform_values { |paths| Array(paths).to_set }
825
+ end
826
+
827
+ # all_examples / all_files: inner hashes whose keys 1.x
828
+ # symbolizes after parse.
829
+ def deserialize_symbolized(raw)
830
+ return {} unless raw.is_a?(Hash)
831
+
832
+ raw.transform_values do |inner|
833
+ inner.is_a?(Hash) ? inner.transform_keys(&:to_sym) : inner
834
+ end
835
+ end
836
+
837
+ # duplicate_examples: Hash[id => Array<Hash>]; each inner hash's
838
+ # keys symbolized post-parse.
839
+ def deserialize_dupe_examples(raw)
840
+ return {} unless raw.is_a?(Hash)
841
+
842
+ raw.transform_values do |list|
843
+ next list unless list.is_a?(Array)
844
+
845
+ list.map { |entry| entry.is_a?(Hash) ? entry.transform_keys(&:to_sym) : entry }
846
+ end
847
+ end
848
+
849
+ # Internal method on the tracer pipeline.
850
+ # @api private
851
+ def info(message)
852
+ @logger&.info(message)
853
+ end
854
+
855
+ # Map the user-facing `:json` / `:msgpack` name onto the
856
+ # concrete Serializer class. A `:msgpack` request with the
857
+ # msgpack gem absent warns once and falls back to `:json` so
858
+ # the user's test suite keeps running (graceful-degradation
859
+ # contract; same posture as the remote-cache optional deps).
860
+ def resolve_serializer(name)
861
+ case name
862
+ when :json
863
+ Serializer::Json
864
+ when :msgpack
865
+ Serializer::Msgpack.available? ? Serializer::Msgpack : msgpack_unavailable_fallback
866
+ else
867
+ raise ArgumentError,
868
+ "unknown serializer: #{name.inspect}; allowed: [:json, :msgpack]"
869
+ end
870
+ end
871
+
872
+ # Internal method on the tracer pipeline.
873
+ # @api private
874
+ def msgpack_unavailable_fallback
875
+ @logger&.warn(
876
+ 'rspec-tracer cache: msgpack gem is not installed; falling back to :json. ' \
877
+ "Add `gem 'msgpack'` to your Gemfile to use the :msgpack serializer."
878
+ )
879
+ Serializer::Json
880
+ end
881
+ end
882
+ # rubocop:enable Metrics/ClassLength
883
+ end
884
+ end