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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +384 -67
- data/README.md +454 -429
- data/bin/rspec-tracer +15 -0
- data/lib/rspec_tracer/cache/Rakefile +43 -0
- data/lib/rspec_tracer/cli/cache_clear.rb +111 -0
- data/lib/rspec_tracer/cli/cache_info.rb +104 -0
- data/lib/rspec_tracer/cli/doctor.rb +284 -0
- data/lib/rspec_tracer/cli/explain.rb +158 -0
- data/lib/rspec_tracer/cli/report_open.rb +82 -0
- data/lib/rspec_tracer/cli.rb +116 -0
- data/lib/rspec_tracer/configuration.rb +1196 -3
- data/lib/rspec_tracer/engine.rb +1168 -0
- data/lib/rspec_tracer/example.rb +141 -11
- data/lib/rspec_tracer/filter.rb +35 -0
- data/lib/rspec_tracer/line_stub.rb +61 -0
- data/lib/rspec_tracer/load_config.rb +2 -2
- data/lib/rspec_tracer/logger.rb +15 -0
- data/lib/rspec_tracer/rails/README.md +78 -0
- data/lib/rspec_tracer/rails/i18n_tracking.rb +137 -0
- data/lib/rspec_tracer/rails/notifications.rb +263 -0
- data/lib/rspec_tracer/rails/preset.rb +94 -0
- data/lib/rspec_tracer/rails/railtie.rb +22 -0
- data/lib/rspec_tracer/rails.rb +15 -0
- data/lib/rspec_tracer/remote_cache/README.md +140 -0
- data/lib/rspec_tracer/remote_cache/Rakefile +35 -11
- data/lib/rspec_tracer/remote_cache/archive.rb +137 -0
- data/lib/rspec_tracer/remote_cache/backend.rb +73 -0
- data/lib/rspec_tracer/remote_cache/git_ancestry.rb +241 -0
- data/lib/rspec_tracer/remote_cache/local_fs_backend.rb +439 -0
- data/lib/rspec_tracer/remote_cache/redis_backend.rb +554 -0
- data/lib/rspec_tracer/remote_cache/s3_backend.rb +712 -0
- data/lib/rspec_tracer/remote_cache/user_tasks.rb +436 -0
- data/lib/rspec_tracer/remote_cache/validator.rb +40 -62
- data/lib/rspec_tracer/remote_cache.rb +22 -0
- data/lib/rspec_tracer/reporters/README.md +103 -0
- data/lib/rspec_tracer/reporters/base.rb +87 -0
- data/lib/rspec_tracer/reporters/coverage_json_reporter.rb +338 -0
- data/lib/rspec_tracer/reporters/html/.gitignore +19 -0
- data/lib/rspec_tracer/reporters/html/.prettierignore +4 -0
- data/lib/rspec_tracer/reporters/html/.prettierrc.json +9 -0
- data/lib/rspec_tracer/reporters/html/README.md +80 -0
- data/lib/rspec_tracer/reporters/html/dist/assets/index.css +2 -0
- data/lib/rspec_tracer/reporters/html/dist/assets/index.js +1 -0
- data/lib/rspec_tracer/reporters/html/dist/index.html +24 -0
- data/lib/rspec_tracer/reporters/html/eslint.config.js +62 -0
- data/lib/rspec_tracer/reporters/html/package-lock.json +4941 -0
- data/lib/rspec_tracer/reporters/html/package.json +29 -0
- data/lib/rspec_tracer/reporters/html/src/app.jsx +130 -0
- data/lib/rspec_tracer/reporters/html/src/components/AllExamples.jsx +86 -0
- data/lib/rspec_tracer/reporters/html/src/components/DuplicateExamples.jsx +68 -0
- data/lib/rspec_tracer/reporters/html/src/components/ExamplesDependency.jsx +78 -0
- data/lib/rspec_tracer/reporters/html/src/components/FilesDependency.jsx +72 -0
- data/lib/rspec_tracer/reporters/html/src/components/FlakyExamples.jsx +42 -0
- data/lib/rspec_tracer/reporters/html/src/components/ReportTable.jsx +131 -0
- data/lib/rspec_tracer/reporters/html/src/components/SearchBar.jsx +19 -0
- data/lib/rspec_tracer/reporters/html/src/index.html +23 -0
- data/lib/rspec_tracer/reporters/html/src/main.jsx +37 -0
- data/lib/rspec_tracer/reporters/html/src/styles.css +434 -0
- data/lib/rspec_tracer/reporters/html/vite.config.js +42 -0
- data/lib/rspec_tracer/reporters/html_reporter.rb +266 -0
- data/lib/rspec_tracer/reporters/json_reporter.rb +88 -0
- data/lib/rspec_tracer/reporters/payload_builder.rb +235 -0
- data/lib/rspec_tracer/reporters/registry.rb +120 -0
- data/lib/rspec_tracer/reporters/terminal_reporter.rb +264 -0
- data/lib/rspec_tracer/rspec/README.md +73 -0
- data/lib/rspec_tracer/rspec/installation.rb +97 -0
- data/lib/rspec_tracer/rspec/metadata.rb +96 -0
- data/lib/rspec_tracer/rspec/parallel_tests.rb +459 -0
- data/lib/rspec_tracer/rspec/reporter_hook.rb +84 -0
- data/lib/rspec_tracer/rspec/runner_hook.rb +239 -0
- data/lib/rspec_tracer/source_file.rb +24 -7
- data/lib/rspec_tracer/storage/README.md +35 -0
- data/lib/rspec_tracer/storage/backend.rb +130 -0
- data/lib/rspec_tracer/storage/json_backend.rb +884 -0
- data/lib/rspec_tracer/storage/lazy_snapshot.rb +65 -0
- data/lib/rspec_tracer/storage/schema.rb +50 -0
- data/lib/rspec_tracer/storage/serializer/json.rb +41 -0
- data/lib/rspec_tracer/storage/serializer/msgpack.rb +167 -0
- data/lib/rspec_tracer/storage/snapshot.rb +141 -0
- data/lib/rspec_tracer/storage/sqlite_backend.rb +693 -0
- data/lib/rspec_tracer/time_formatter.rb +37 -18
- data/lib/rspec_tracer/tracker/README.md +36 -0
- data/lib/rspec_tracer/tracker/coverage_adapter.rb +174 -0
- data/lib/rspec_tracer/tracker/declared_globs.rb +100 -0
- data/lib/rspec_tracer/tracker/dependency_graph.rb +134 -0
- data/lib/rspec_tracer/tracker/env_matcher.rb +127 -0
- data/lib/rspec_tracer/tracker/env_snapshot.rb +77 -0
- data/lib/rspec_tracer/tracker/example_registry.rb +153 -0
- data/lib/rspec_tracer/tracker/file_digest.rb +61 -0
- data/lib/rspec_tracer/tracker/filter.rb +127 -0
- data/lib/rspec_tracer/tracker/input.rb +99 -0
- data/lib/rspec_tracer/tracker/io_hooks/file.rb +55 -0
- data/lib/rspec_tracer/tracker/io_hooks/io.rb +24 -0
- data/lib/rspec_tracer/tracker/io_hooks/json.rb +23 -0
- data/lib/rspec_tracer/tracker/io_hooks/kernel.rb +26 -0
- data/lib/rspec_tracer/tracker/io_hooks/yaml.rb +38 -0
- data/lib/rspec_tracer/tracker/io_hooks.rb +195 -0
- data/lib/rspec_tracer/tracker/loaded_files_tracker.rb +295 -0
- data/lib/rspec_tracer/tracker/new_file_detector.rb +62 -0
- data/lib/rspec_tracer/tracker/whole_suite_invalidators.rb +96 -0
- data/lib/rspec_tracer/version.rb +4 -1
- data/lib/rspec_tracer.rb +231 -491
- metadata +94 -43
- data/lib/rspec_tracer/cache.rb +0 -207
- data/lib/rspec_tracer/coverage_merger.rb +0 -42
- data/lib/rspec_tracer/coverage_reporter.rb +0 -187
- data/lib/rspec_tracer/coverage_writer.rb +0 -58
- data/lib/rspec_tracer/html_reporter/Rakefile +0 -18
- data/lib/rspec_tracer/html_reporter/assets/javascripts/application.js +0 -56
- data/lib/rspec_tracer/html_reporter/assets/javascripts/libraries/jquery.js +0 -10881
- data/lib/rspec_tracer/html_reporter/assets/javascripts/plugins/datatables.js +0 -15381
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/application.css +0 -196
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/datatables.css +0 -459
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/jquery-ui.css +0 -436
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/print.css +0 -92
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/reset.css +0 -265
- data/lib/rspec_tracer/html_reporter/public/application.css +0 -5
- data/lib/rspec_tracer/html_reporter/public/application.js +0 -6
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc_disabled.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_both.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc_disabled.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/favicon.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/loading.gif +0 -0
- data/lib/rspec_tracer/html_reporter/reporter.rb +0 -242
- data/lib/rspec_tracer/html_reporter/views/duplicate_examples.erb +0 -34
- data/lib/rspec_tracer/html_reporter/views/examples.erb +0 -58
- data/lib/rspec_tracer/html_reporter/views/examples_dependency.erb +0 -36
- data/lib/rspec_tracer/html_reporter/views/files_dependency.erb +0 -36
- data/lib/rspec_tracer/html_reporter/views/flaky_examples.erb +0 -38
- data/lib/rspec_tracer/html_reporter/views/layout.erb +0 -38
- data/lib/rspec_tracer/remote_cache/aws.rb +0 -176
- data/lib/rspec_tracer/remote_cache/cache.rb +0 -75
- data/lib/rspec_tracer/remote_cache/repo.rb +0 -210
- data/lib/rspec_tracer/report_generator.rb +0 -158
- data/lib/rspec_tracer/report_merger.rb +0 -68
- data/lib/rspec_tracer/report_writer.rb +0 -141
- data/lib/rspec_tracer/reporter.rb +0 -204
- data/lib/rspec_tracer/rspec_reporter.rb +0 -41
- data/lib/rspec_tracer/rspec_runner.rb +0 -56
- data/lib/rspec_tracer/ruby_coverage.rb +0 -9
- 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
|