rspec-tracer 1.2.2 → 2.0.0.pre.1

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 +197 -45
  3. data/README.md +439 -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 +98 -0
  7. data/lib/rspec_tracer/cli/cache_info.rb +103 -0
  8. data/lib/rspec_tracer/cli/doctor.rb +275 -0
  9. data/lib/rspec_tracer/cli/explain.rb +148 -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 +1100 -3
  13. data/lib/rspec_tracer/engine.rb +1076 -0
  14. data/lib/rspec_tracer/example.rb +21 -6
  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 +397 -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 +178 -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 +68 -0
  75. data/lib/rspec_tracer/storage/json_backend.rb +866 -0
  76. data/lib/rspec_tracer/storage/lazy_snapshot.rb +65 -0
  77. data/lib/rspec_tracer/storage/schema.rb +43 -0
  78. data/lib/rspec_tracer/storage/serializer/json.rb +41 -0
  79. data/lib/rspec_tracer/storage/serializer/msgpack.rb +90 -0
  80. data/lib/rspec_tracer/storage/snapshot.rb +127 -0
  81. data/lib/rspec_tracer/storage/sqlite_backend.rb +686 -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 +232 -381
  104. metadata +93 -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,554 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+
6
+ require_relative 'archive'
7
+ require_relative 'backend'
8
+ require_relative 'validator'
9
+
10
+ module RSpecTracer
11
+ # Internal RemoteCache — see {RSpecTracer} for the user-facing surface.
12
+ # @api private
13
+ module RemoteCache
14
+ # Redis implementation of `RemoteCache::Backend`. Each cache ref is
15
+ # one Redis hash keyed under the two-tier layout:
16
+ #
17
+ # <prefix>:main:<sha>[:<test_suite_id>] -> HASH
18
+ # <prefix>:pr:<branch>:<sha>[:<test_suite_id>] -> HASH
19
+ # <prefix>:pr:<branch>:branch_refs -> STRING (JSON)
20
+ #
21
+ # Hash fields per ref:
22
+ # _timestamp -> epoch float (string; microsecond resolution
23
+ # to keep within-second orderings stable for
24
+ # count-based prune)
25
+ # last_run.json -> JSON content verbatim
26
+ # <run_id>/<f>.json -> JSON content per file in the 15-file layout
27
+ #
28
+ # Why hashmap and not a binary archive (like S3 / LocalFs): hash-
29
+ # per-ref is the idiomatic Redis data model, matches the brief, and
30
+ # gives operational visibility via `redis-cli HGETALL` /
31
+ # `HKEYS` / `HLEN` without extracting an archive first. The storage
32
+ # cost (no gzip) is negligible for realistic cache sizes.
33
+ #
34
+ # Retention: identical dispatch to S3 / LocalFs. The orchestrator
35
+ # calls `prune!(count:, duration_seconds:, pr_branch_ttl_seconds:)`
36
+ # after each upload; this backend enumerates via SCAN + HGET on the
37
+ # per-ref `_timestamp` field, then DELs stale keys. TTL-on-SET (i.e.
38
+ # letting Redis EXPIRE handle it natively) is a reasonable ergonomic
39
+ # followup but is not required for correctness - the explicit prune
40
+ # pass already achieves the same eviction outcome.
41
+ #
42
+ # Graceful-degradation contract:
43
+ # - `redis` gem missing -> constructor raises RedisBackendError;
44
+ # UserTasks rescues at the top, logs a clear "add gem to your
45
+ # Gemfile" message, falls back to cold run. Never propagates.
46
+ # - Wire failure (connection refused, timeout) -> redis-rb raises
47
+ # Redis::BaseError subclasses. `download` catches and returns
48
+ # false; `upload` lets them propagate for the Rake task to log.
49
+ # Same rescue model as S3Backend.
50
+ #
51
+ # The `redis` gem is an OPTIONAL runtime dependency - users add
52
+ # `gem 'redis'` to their own Gemfile to use RedisBackend. The
53
+ # constructor calls `require 'redis'` lazily and raises a clear
54
+ # RedisBackendError if the gem is absent, which UserTasks converts
55
+ # into a warning + cold run.
56
+ class RedisBackend
57
+ # Internal RedisBackendError — see {RSpecTracer} for the user-facing surface.
58
+ # @api private
59
+ class RedisBackendError < StandardError; end
60
+
61
+ # Internal constant.
62
+ # @api private
63
+ MAIN_TIER = 'main'
64
+ # Internal constant.
65
+ # @api private
66
+ PR_TIER = 'pr'
67
+ # Internal constant.
68
+ # @api private
69
+ BRANCH_REFS_SUFFIX = 'branch_refs'
70
+ # Internal constant.
71
+ # @api private
72
+ PR_BRANCHES_SUFFIX = 'pr_branches'
73
+ # Internal constant.
74
+ # @api private
75
+ LAST_RUN_FIELD = 'last_run.json'
76
+ # Internal constant.
77
+ # @api private
78
+ TIMESTAMP_FIELD = '_timestamp'
79
+ # Internal constant.
80
+ # @api private
81
+ ENCODING = 'UTF-8'
82
+ # Internal constant.
83
+ # @api private
84
+ DEFAULT_SCAN_COUNT = 200
85
+
86
+ # rubocop:disable Metrics/ParameterLists
87
+ def initialize(prefix:, branch:, default_branch:, cache_path:,
88
+ url: nil, redis_client: nil, test_suite_id: nil, logger: nil,
89
+ ttl: nil)
90
+ validate_required!(prefix: prefix, branch: branch,
91
+ default_branch: default_branch, cache_path: cache_path)
92
+ validate_connection_source!(url: url, redis_client: redis_client)
93
+ validate_ttl!(ttl)
94
+
95
+ @prefix = trim_trailing_colons(prefix.to_s)
96
+ @branch = branch.to_s.chomp
97
+ @default_branch = default_branch.to_s.chomp
98
+ @test_suite_id = normalize_test_suite_id(test_suite_id)
99
+ @cache_path = cache_path.to_s
100
+ @logger = logger
101
+ @ttl = ttl
102
+ @redis = redis_client || build_client(url)
103
+ end
104
+ # rubocop:enable Metrics/ParameterLists
105
+
106
+ # Download cache for `ref`. Tries own tier, falls back to main
107
+ # tier. Returns true on validated success, false otherwise.
108
+ # Cleans up partially-written files on failure.
109
+ #
110
+ # `tree_sha:` is accepted for protocol uniformity with S3Backend
111
+ # but is currently a no-op: the tree-SHA secondary index is an
112
+ # S3-only feature. Future enhancement may extend it here; the
113
+ # orchestrator already forwards the kwarg.
114
+ def download(ref, tree_sha: nil)
115
+ _ = tree_sha
116
+ return false if blank?(ref)
117
+
118
+ tiers_to_try = [own_tier_segment]
119
+ tiers_to_try << MAIN_TIER if pr_tier?
120
+
121
+ tiers_to_try.any? { |tier| try_download_from(tier, ref) }
122
+ end
123
+
124
+ # Upload local cache as a hash under own-tier key. Raises on I/O
125
+ # or Redis wire failure.
126
+ #
127
+ # `tree_sha:` is accepted for protocol uniformity with S3Backend
128
+ # (no-op here; see `download`).
129
+ def upload(ref, tree_sha: nil)
130
+ _ = tree_sha
131
+ raise RedisBackendError, 'ref is required' if blank?(ref)
132
+
133
+ run_id = read_local_run_id
134
+ raise RedisBackendError, "no local cache to upload (missing #{LAST_RUN_FIELD})" if run_id.nil?
135
+
136
+ fields = build_upload_fields(run_id)
137
+ key = ref_key(own_tier_segment, ref)
138
+ write_upload_hash(key, fields)
139
+ log_debug("uploaded cache for #{ref} to #{own_tier_segment} (#{fields.size} fields)")
140
+ end
141
+
142
+ # Read branch_refs for the given branch. Returns
143
+ # `{sha => ts_epoch}` or `{}` when missing / malformed.
144
+ def branch_refs(branch_name)
145
+ return {} if blank?(branch_name)
146
+
147
+ raw = @redis.get(branch_refs_key(branch_name))
148
+ return {} if raw.nil? || raw.empty?
149
+
150
+ parsed = JSON.parse(raw)
151
+ parsed.is_a?(Hash) ? parsed.transform_values(&:to_i) : {}
152
+ rescue StandardError => e
153
+ log_debug("branch_refs read failed (#{e.class}: #{e.message}); treating as empty")
154
+ {}
155
+ end
156
+
157
+ # Persist branch_refs for the given branch. No-op for main-branch
158
+ # writes. Raises on Redis wire failure for PR tier.
159
+ def write_branch_refs(branch_name, refs)
160
+ return if blank?(branch_name)
161
+ return if branch_name.to_s.chomp == @default_branch
162
+ return if refs.nil? || refs.empty?
163
+
164
+ @redis.set(branch_refs_key(branch_name), JSON.pretty_generate(refs))
165
+ log_debug("wrote branch_refs for #{branch_name}")
166
+ end
167
+
168
+ # Apply retention to own tier. Returns count removed. Two or more
169
+ # knobs may be set; each applies independently. Never raises - a
170
+ # wire-level Redis error on any sub-prune logs + is absorbed.
171
+ # rubocop:disable Metrics/PerceivedComplexity
172
+ def prune!(count: nil, duration_seconds: nil, pr_branch_ttl_seconds: nil)
173
+ removed = 0
174
+ removed += prune_by_count!(count) if count&.positive?
175
+ removed += prune_by_duration!(duration_seconds) if duration_seconds&.positive?
176
+ removed += prune_dead_pr_branch!(pr_branch_ttl_seconds) if pr_tier? && pr_branch_ttl_seconds&.positive?
177
+ removed
178
+ rescue StandardError => e
179
+ log_warn("prune! failed (#{e.class}: #{e.message}); returning #{removed}")
180
+ removed
181
+ end
182
+ # rubocop:enable Metrics/PerceivedComplexity
183
+
184
+ # Cross-tier PR-branch cleanup. Enumerates every PR branch under
185
+ # the configured prefix (by scanning for `<prefix>:pr:<branch>:branch_refs`
186
+ # keys and deriving branch names), applies the TTL to each,
187
+ # deletes dead branches whole. Returns total refs removed. No-op
188
+ # on nil / non-positive TTL.
189
+ def prune_all!(pr_branch_ttl_seconds: nil)
190
+ return 0 unless pr_branch_ttl_seconds&.positive?
191
+
192
+ cutoff = Time.now.to_i - pr_branch_ttl_seconds.to_i
193
+ branches = discover_pr_branches
194
+ branches.sum { |branch| maybe_prune_branch(branch, cutoff) }
195
+ rescue StandardError => e
196
+ log_warn("prune_all! failed (#{e.class}: #{e.message})")
197
+ 0
198
+ end
199
+
200
+ # Warn when main tier has grown beyond threshold and no retention
201
+ # is configured.
202
+ def unbounded_warning(warn_threshold: 500)
203
+ count = count_tier_refs(MAIN_TIER)
204
+ return nil unless count > warn_threshold
205
+
206
+ "rspec-tracer remote cache has #{count} refs in #{@prefix}:#{MAIN_TIER}; " \
207
+ 'configure cache_retention_count or cache_retention_duration to cap growth'
208
+ end
209
+
210
+ private
211
+
212
+ # Internal method on the tracer pipeline.
213
+ # @api private
214
+ def blank?(value)
215
+ value.nil? || value.to_s.empty?
216
+ end
217
+
218
+ # Internal method on the tracer pipeline.
219
+ # @api private
220
+ def validate_required!(**opts)
221
+ opts.each do |key, value|
222
+ raise RedisBackendError, "#{key} is required" if blank?(value)
223
+ end
224
+ end
225
+
226
+ # Internal method on the tracer pipeline.
227
+ # @api private
228
+ def validate_connection_source!(url:, redis_client:)
229
+ return if redis_client
230
+ raise RedisBackendError, 'url or redis_client is required' if blank?(url)
231
+ end
232
+
233
+ # Optional kwarg. nil disables TTL (per-key persistence determined
234
+ # by the user's Redis eviction policy). Positive integer enables
235
+ # `EXPIRE <key> <ttl>` inside the upload's MULTI block, atomic
236
+ # with the SET. Mirrors `cache_retention_count`-style validation.
237
+ def validate_ttl!(ttl)
238
+ return if ttl.nil?
239
+ return if ttl.is_a?(::Integer) && ttl.positive?
240
+
241
+ raise RedisBackendError,
242
+ "ttl must be a positive integer (seconds) or nil, got #{ttl.inspect}"
243
+ end
244
+
245
+ # Internal method on the tracer pipeline.
246
+ # @api private
247
+ def build_client(url)
248
+ require 'redis'
249
+ ::Redis.new(url: url)
250
+ rescue LoadError
251
+ raise RedisBackendError,
252
+ "redis gem is not installed; add `gem 'redis'` to your Gemfile to use RedisBackend"
253
+ end
254
+
255
+ # Internal method on the tracer pipeline.
256
+ # @api private
257
+ def normalize_test_suite_id(raw)
258
+ return nil if raw.nil?
259
+
260
+ value = raw.to_s
261
+ value.empty? ? nil : value
262
+ end
263
+
264
+ # Internal method on the tracer pipeline.
265
+ # @api private
266
+ def trim_trailing_colons(str)
267
+ value = str.dup
268
+ value.chop! while value.end_with?(':')
269
+ value
270
+ end
271
+
272
+ # Internal method on the tracer pipeline.
273
+ # @api private
274
+ def pr_tier?
275
+ @branch != @default_branch
276
+ end
277
+
278
+ # Internal method on the tracer pipeline.
279
+ # @api private
280
+ def own_tier_segment
281
+ pr_tier? ? "#{PR_TIER}:#{@branch}" : MAIN_TIER
282
+ end
283
+
284
+ # Internal method on the tracer pipeline.
285
+ # @api private
286
+ def ref_key(tier_segment, ref)
287
+ [@prefix, tier_segment, ref, @test_suite_id].compact.reject(&:empty?).join(':')
288
+ end
289
+
290
+ # Internal method on the tracer pipeline.
291
+ # @api private
292
+ def branch_refs_key(branch_name)
293
+ [@prefix, PR_TIER, branch_name.chomp, BRANCH_REFS_SUFFIX].join(':')
294
+ end
295
+
296
+ # Internal method on the tracer pipeline.
297
+ # @api private
298
+ def local_last_run_path
299
+ File.join(@cache_path, LAST_RUN_FIELD)
300
+ end
301
+
302
+ # Internal method on the tracer pipeline.
303
+ # @api private
304
+ def local_run_dir(run_id)
305
+ File.join(@cache_path, run_id)
306
+ end
307
+
308
+ # Internal method on the tracer pipeline.
309
+ # @api private
310
+ def read_local_run_id
311
+ return nil unless File.file?(local_last_run_path)
312
+
313
+ manifest = JSON.parse(File.read(local_last_run_path, encoding: ENCODING))
314
+ return nil unless manifest.is_a?(Hash)
315
+
316
+ run_id = manifest['run_id']
317
+ return nil if blank?(run_id)
318
+
319
+ run_id
320
+ rescue StandardError
321
+ nil
322
+ end
323
+
324
+ # Internal method on the tracer pipeline.
325
+ # @api private
326
+ def build_upload_fields(run_id)
327
+ # Float seconds, not integer - multiple uploads within the
328
+ # same clock second otherwise collide on _timestamp and make
329
+ # count-based prune pick arbitrary survivors. Float keeps
330
+ # microsecond ordering stable under realistic CI cadences.
331
+ fields = { TIMESTAMP_FIELD => Time.now.to_f.to_s }
332
+ fields[LAST_RUN_FIELD] = File.read(local_last_run_path, encoding: ENCODING)
333
+ Dir[File.join(local_run_dir(run_id), '*.json')].each do |path|
334
+ fields["#{run_id}/#{File.basename(path)}"] = File.read(path, encoding: ENCODING)
335
+ end
336
+ fields
337
+ end
338
+
339
+ # Upload MULTI: DEL + HSET + optional EXPIRE + optional sidecar
340
+ # SADD, all atomic under one round-trip.
341
+ #
342
+ # DEL flushes any stale fields from a prior upload under the same
343
+ # ref before HSET installs the new field set (prevents partial
344
+ # overlay when a retried upload has a subset of keys the first
345
+ # attempt had). EXPIRE fires only when @ttl is set (per-key TTL
346
+ # atomic with the SET; nil means no TTL, user controls eviction
347
+ # via Redis policy + the explicit prune pass). SADD into the
348
+ # PR-branches sidecar fires only on PR-tier uploads (main-tier
349
+ # uploads skip; sidecar is operational telemetry for ops
350
+ # dashboards via SMEMBERS).
351
+ def write_upload_hash(key, fields)
352
+ @redis.multi do |tx|
353
+ tx.del(key)
354
+ tx.hset(key, fields)
355
+ tx.expire(key, @ttl) if @ttl
356
+ tx.sadd(pr_branches_key, @branch) if pr_tier?
357
+ end
358
+ end
359
+
360
+ # Sidecar key tracking which PR branches have ever uploaded to
361
+ # this prefix. Operational telemetry for ops dashboards
362
+ # (`SMEMBERS <prefix>:pr_branches`); does not affect download
363
+ # semantics. Unconditionally namespaced under the configured
364
+ # prefix; PR-tier uploads SADD into it, main-tier uploads
365
+ # skip it.
366
+ def pr_branches_key
367
+ "#{@prefix}:#{PR_BRANCHES_SUFFIX}"
368
+ end
369
+
370
+ # Internal method on the tracer pipeline.
371
+ # @api private
372
+ def try_download_from(tier_segment, ref)
373
+ key = ref_key(tier_segment, ref)
374
+ fields = @redis.hgetall(key)
375
+ return false if fields.nil? || fields.empty?
376
+
377
+ extract_and_validate(fields, tier_segment, ref)
378
+ rescue StandardError => e
379
+ log_debug("download failed for #{tier_segment}/#{ref}: #{e.class}: #{e.message}")
380
+ rollback_extracted_cache
381
+ false
382
+ end
383
+
384
+ # Action-style method (writes files + cleans up on failure), not a
385
+ # pure predicate. The bool return is the "did we land a valid
386
+ # cache" signal; renaming to `?` would misread the method as a
387
+ # query that happens to perform I/O.
388
+ # rubocop:disable Naming/PredicateMethod
389
+ def extract_and_validate(fields, tier_segment, ref)
390
+ write_fields_to_disk(fields)
391
+
392
+ return true if Validator.valid_file?(local_last_run_path)
393
+
394
+ log_debug("rejected #{tier_segment}/#{ref}: schema_version mismatch")
395
+ rollback_extracted_cache
396
+ false
397
+ end
398
+ # rubocop:enable Naming/PredicateMethod
399
+
400
+ def write_fields_to_disk(fields)
401
+ FileUtils.mkdir_p(@cache_path)
402
+ fields.each do |field, content|
403
+ next if field == TIMESTAMP_FIELD
404
+
405
+ safe_name = Archive.safe_entry_name(field)
406
+ next if safe_name.nil?
407
+
408
+ dest = File.join(@cache_path, safe_name)
409
+ FileUtils.mkdir_p(File.dirname(dest))
410
+ File.write(dest, content, encoding: ENCODING)
411
+ end
412
+ end
413
+
414
+ # Internal method on the tracer pipeline.
415
+ # @api private
416
+ def rollback_extracted_cache
417
+ run_id = read_local_run_id
418
+ FileUtils.rm_f(local_last_run_path)
419
+ FileUtils.rm_rf(local_run_dir(run_id)) if run_id
420
+ end
421
+
422
+ # List refs in `tier_segment` as Array<[key, ts_epoch]>,
423
+ # newest-first. Returns full Redis keys (not just the ref sha)
424
+ # because prune_by_* deletes those keys directly.
425
+ def list_refs_in_tier(tier_segment)
426
+ pattern = "#{@prefix}:#{tier_segment}:*"
427
+ keys = scan_matching_keys(pattern).reject { |k| branch_refs_key?(k) }
428
+ return [] if keys.empty?
429
+
430
+ entries = keys.map { |k| [k, fetch_timestamp(k)] }.reject { |(_, ts)| ts.nil? }
431
+ entries.sort_by { |(_, ts)| -ts }
432
+ end
433
+
434
+ # Internal method on the tracer pipeline.
435
+ # @api private
436
+ def scan_matching_keys(pattern)
437
+ @redis.scan_each(match: pattern, count: DEFAULT_SCAN_COUNT).to_a
438
+ end
439
+
440
+ # Internal method on the tracer pipeline.
441
+ # @api private
442
+ def branch_refs_key?(key)
443
+ key.end_with?(":#{BRANCH_REFS_SUFFIX}")
444
+ end
445
+
446
+ # Internal method on the tracer pipeline.
447
+ # @api private
448
+ def fetch_timestamp(key)
449
+ raw = @redis.hget(key, TIMESTAMP_FIELD)
450
+ return nil if raw.nil?
451
+
452
+ raw.to_f
453
+ end
454
+
455
+ # Internal method on the tracer pipeline.
456
+ # @api private
457
+ def count_tier_refs(tier_segment)
458
+ pattern = "#{@prefix}:#{tier_segment}:*"
459
+ scan_matching_keys(pattern).count { |k| !branch_refs_key?(k) }
460
+ end
461
+
462
+ # Internal method on the tracer pipeline.
463
+ # @api private
464
+ def prune_by_count!(count)
465
+ entries = list_refs_in_tier(own_tier_segment)
466
+ return 0 if entries.length <= count
467
+
468
+ to_delete = entries[count..] || []
469
+ delete_keys(to_delete.map(&:first))
470
+ end
471
+
472
+ # Internal method on the tracer pipeline.
473
+ # @api private
474
+ def prune_by_duration!(duration_seconds)
475
+ cutoff = Time.now.to_i - duration_seconds.to_i
476
+ stale_keys = list_refs_in_tier(own_tier_segment)
477
+ .select { |(_, ts)| ts < cutoff }
478
+ .map(&:first)
479
+ delete_keys(stale_keys)
480
+ end
481
+
482
+ # Internal method on the tracer pipeline.
483
+ # @api private
484
+ def delete_keys(keys)
485
+ return 0 if keys.empty?
486
+
487
+ @redis.del(*keys)
488
+ keys.each { |k| log_debug("pruned key #{k}") }
489
+ keys.length
490
+ end
491
+
492
+ # Internal method on the tracer pipeline.
493
+ # @api private
494
+ def prune_dead_pr_branch!(ttl_seconds)
495
+ entries = list_refs_in_tier(own_tier_segment)
496
+ return 0 if entries.empty?
497
+
498
+ newest_ts = entries.first[1]
499
+ return 0 if newest_ts >= Time.now.to_i - ttl_seconds.to_i
500
+
501
+ delete_branch_prefix(@branch, entries.length)
502
+ end
503
+
504
+ # Delete every cache key + branch_refs under a given PR branch.
505
+ # Returns the supplied `ref_count` on success, 0 on failure.
506
+ def delete_branch_prefix(branch_name, ref_count)
507
+ pattern = "#{@prefix}:#{PR_TIER}:#{branch_name}:*"
508
+ keys = scan_matching_keys(pattern)
509
+ @redis.del(*keys) unless keys.empty?
510
+ log_debug("pruned dead PR branch #{PR_TIER}:#{branch_name} (#{keys.length} keys)")
511
+ ref_count
512
+ rescue StandardError => e
513
+ log_warn("failed to prune dead PR branch #{PR_TIER}:#{branch_name}: #{e.class}: #{e.message}")
514
+ 0
515
+ end
516
+
517
+ # Internal method on the tracer pipeline.
518
+ # @api private
519
+ def discover_pr_branches
520
+ pattern = "#{@prefix}:#{PR_TIER}:*:#{BRANCH_REFS_SUFFIX}"
521
+ prefix_head = "#{@prefix}:#{PR_TIER}:"
522
+ scan_matching_keys(pattern).map do |key|
523
+ remainder = key[prefix_head.length..]
524
+ remainder.delete_suffix(":#{BRANCH_REFS_SUFFIX}")
525
+ end
526
+ end
527
+
528
+ # Internal method on the tracer pipeline.
529
+ # @api private
530
+ def maybe_prune_branch(branch_name, cutoff)
531
+ tier_segment = "#{PR_TIER}:#{branch_name}"
532
+ entries = list_refs_in_tier(tier_segment)
533
+ return 0 if entries.empty?
534
+
535
+ newest_ts = entries.first[1]
536
+ return 0 if newest_ts >= cutoff
537
+
538
+ delete_branch_prefix(branch_name, entries.length)
539
+ end
540
+
541
+ # Internal method on the tracer pipeline.
542
+ # @api private
543
+ def log_debug(message)
544
+ @logger&.debug("rspec-tracer remote_cache: #{message}")
545
+ end
546
+
547
+ # Internal method on the tracer pipeline.
548
+ # @api private
549
+ def log_warn(message)
550
+ @logger&.warn("rspec-tracer remote_cache: #{message}")
551
+ end
552
+ end
553
+ end
554
+ end