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,439 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'securerandom'
6
+
7
+ require_relative 'archive'
8
+ require_relative 'backend'
9
+ require_relative 'validator'
10
+
11
+ module RSpecTracer
12
+ # Internal RemoteCache — see {RSpecTracer} for the user-facing surface.
13
+ # @api private
14
+ module RemoteCache
15
+ # Filesystem implementation of `RemoteCache::Backend`. Target is
16
+ # a shared directory: an NFS mount, a per-host dev cache, or a CI
17
+ # workspace volume. Two-tier layout mirrors `S3Backend` bit-for-bit;
18
+ # a LocalFs root directory can be rsync'd to/from S3 without any
19
+ # transform (same `cache.tar.gz` per ref, same `branch_refs.json`
20
+ # path, same tier prefixes).
21
+ #
22
+ # <root>/main/<sha>/[<test_suite_id>/]cache.tar.gz
23
+ # <root>/pr/<branch>/<sha>/[<test_suite_id>/]cache.tar.gz
24
+ # <root>/pr/<branch>/branch_refs.json
25
+ #
26
+ # Uploads are atomic: the archive is staged at a sibling tmp path
27
+ # on the same filesystem, then `File.rename`d into place. POSIX
28
+ # rename is atomic on same-filesystem moves, which covers every
29
+ # shared-mount topology LocalFs targets.
30
+ #
31
+ # Concurrent writes to the same ref: last-write-wins is correct
32
+ # because the archive content is a deterministic function of the
33
+ # local cache (two workers on the same SHA produce identical bytes).
34
+ # No file locking - flock is unreliable over NFS (lockd sharp
35
+ # edges) and buys nothing when contents match.
36
+ #
37
+ # NFS caveat: on a network filesystem, cross-node consistency is
38
+ # eventual. A download issued by node B immediately after an upload
39
+ # on node A may miss; retries converge. Document as user concern,
40
+ # not a backend correctness issue.
41
+ class LocalFsBackend
42
+ # Internal LocalFsBackendError — see {RSpecTracer} for the user-facing surface.
43
+ # @api private
44
+ class LocalFsBackendError < StandardError; end
45
+
46
+ # Internal constant.
47
+ # @api private
48
+ MAIN_TIER = 'main'
49
+ # Internal constant.
50
+ # @api private
51
+ PR_TIER = 'pr'
52
+ # Internal constant.
53
+ # @api private
54
+ BRANCH_REFS_FILENAME = 'branch_refs.json'
55
+ # Internal constant.
56
+ # @api private
57
+ LAST_RUN_FILENAME = 'last_run.json'
58
+ # Internal constant.
59
+ # @api private
60
+ CACHE_ARCHIVE_FILENAME = Archive::CACHE_FILENAME
61
+ # Internal constant.
62
+ # @api private
63
+ ENCODING = 'UTF-8'
64
+
65
+ # rubocop:disable Metrics/ParameterLists
66
+ def initialize(root:, branch:, default_branch:, cache_path:,
67
+ test_suite_id: nil, logger: nil)
68
+ validate_required!(root: root, branch: branch,
69
+ default_branch: default_branch, cache_path: cache_path)
70
+
71
+ @root = File.expand_path(root.to_s)
72
+ @branch = branch.to_s.chomp
73
+ @default_branch = default_branch.to_s.chomp
74
+ @test_suite_id = normalize_test_suite_id(test_suite_id)
75
+ @cache_path = cache_path.to_s
76
+ @logger = logger
77
+ end
78
+ # rubocop:enable Metrics/ParameterLists
79
+
80
+ # Download the cache for `ref` into `cache_path`. Tries the
81
+ # backend's own tier first; on miss, falls back to the main tier
82
+ # for the same ref. Validates via `schema_version` before
83
+ # declaring success. Returns true on validated success, false
84
+ # otherwise. Cleans up partially-extracted state on failure.
85
+ #
86
+ # `tree_sha:` is accepted for protocol uniformity with S3Backend
87
+ # but is currently a no-op: the tree-SHA secondary index is an
88
+ # S3-only feature. Future enhancement may extend it here; the
89
+ # orchestrator already forwards the kwarg.
90
+ def download(ref, tree_sha: nil)
91
+ _ = tree_sha
92
+ return false if blank?(ref)
93
+
94
+ tiers_to_try = [own_tier_prefix]
95
+ tiers_to_try << main_tier_prefix if pr_tier?
96
+
97
+ tiers_to_try.any? { |tier| try_download_from(tier, ref) }
98
+ end
99
+
100
+ # Upload the local cache to this backend's own tier under `ref`.
101
+ # Packs the 15-file local layout into a `cache.tar.gz` via
102
+ # `Archive.pack`, renames into place atomically. Raises on a
103
+ # malformed local cache or an I/O failure.
104
+ #
105
+ # `tree_sha:` is accepted for protocol uniformity with S3Backend
106
+ # (no-op here; see `download`).
107
+ def upload(ref, tree_sha: nil)
108
+ _ = tree_sha
109
+ raise LocalFsBackendError, 'ref is required' if blank?(ref)
110
+
111
+ run_id = read_local_run_id
112
+ raise LocalFsBackendError, "no local cache to upload (missing #{LAST_RUN_FILENAME})" if run_id.nil?
113
+
114
+ dest = archive_path(own_tier_prefix, ref)
115
+ FileUtils.mkdir_p(File.dirname(dest))
116
+ staging = "#{dest}.tmp.#{Process.pid}.#{SecureRandom.hex(4)}"
117
+ begin
118
+ Archive.pack(cache_path: @cache_path, run_id: run_id, dest_path: staging)
119
+ File.rename(staging, dest)
120
+ log_debug("uploaded cache for #{ref} to #{own_tier_prefix} (#{File.size(dest)} bytes)")
121
+ ensure
122
+ FileUtils.rm_f(staging)
123
+ end
124
+ end
125
+
126
+ # Read branch_refs for the given branch. Returns `{sha => ts_epoch}`
127
+ # or `{}` on missing / malformed. PR tier only.
128
+ def branch_refs(branch_name)
129
+ return {} if blank?(branch_name)
130
+
131
+ path = branch_refs_path(branch_name)
132
+ return {} unless File.file?(path)
133
+
134
+ parsed = JSON.parse(File.read(path, encoding: ENCODING))
135
+ parsed.is_a?(Hash) ? parsed.transform_values(&:to_i) : {}
136
+ rescue StandardError => e
137
+ log_debug("branch_refs read failed (#{e.class}: #{e.message}); treating as empty")
138
+ {}
139
+ end
140
+
141
+ # Persist branch_refs for the given branch. No-op for main-branch
142
+ # writes. Atomic via tmp+rename. Raises on I/O failure for PR
143
+ # tier.
144
+ def write_branch_refs(branch_name, refs)
145
+ return if blank?(branch_name)
146
+ return if branch_name.to_s.chomp == @default_branch
147
+ return if refs.nil? || refs.empty?
148
+
149
+ path = branch_refs_path(branch_name)
150
+ FileUtils.mkdir_p(File.dirname(path))
151
+ staging = "#{path}.tmp.#{Process.pid}.#{SecureRandom.hex(4)}"
152
+ begin
153
+ File.write(staging, JSON.pretty_generate(refs), encoding: ENCODING)
154
+ File.rename(staging, path)
155
+ log_debug("wrote branch_refs for #{branch_name}")
156
+ ensure
157
+ FileUtils.rm_f(staging)
158
+ end
159
+ end
160
+
161
+ # Apply retention to the backend's own tier. Returns count removed.
162
+ # Semantics match S3Backend: count keeps newest N, duration prunes
163
+ # by mtime, pr_branch_ttl deletes the whole branch prefix when
164
+ # idle. Two or more params may be set simultaneously; all nil/0
165
+ # is a no-op. Never raises on partial I/O failure.
166
+ def prune!(count: nil, duration_seconds: nil, pr_branch_ttl_seconds: nil)
167
+ removed = 0
168
+ removed += prune_by_count!(count) if count&.positive?
169
+ removed += prune_by_duration!(duration_seconds) if duration_seconds&.positive?
170
+ removed += prune_dead_pr_branch!(pr_branch_ttl_seconds) if pr_tier? && pr_branch_ttl_seconds&.positive?
171
+ removed
172
+ end
173
+
174
+ # Cross-tier PR-branch cleanup. Enumerates every branch dir under
175
+ # `pr/`, applies the ttl to each, deletes branches with no ref
176
+ # newer than the cutoff. Returns total refs removed. No-op on
177
+ # nil / non-positive ttl.
178
+ def prune_all!(pr_branch_ttl_seconds: nil)
179
+ return 0 unless pr_branch_ttl_seconds&.positive?
180
+
181
+ cutoff = Time.now.to_i - pr_branch_ttl_seconds.to_i
182
+ pr_root = File.join(@root, PR_TIER)
183
+ return 0 unless File.directory?(pr_root)
184
+
185
+ branch_dirs(pr_root).sum { |branch_dir| maybe_prune_branch(branch_dir, cutoff) }
186
+ end
187
+
188
+ # Warn when the main tier has grown beyond a soft threshold and
189
+ # no retention is configured. Called from the orchestrator.
190
+ def unbounded_warning(warn_threshold: 500)
191
+ refs = list_refs_in_tier(MAIN_TIER)
192
+ return nil unless refs.length > warn_threshold
193
+
194
+ "rspec-tracer remote cache has #{refs.length} refs in #{@root}/#{MAIN_TIER}; " \
195
+ 'configure cache_retention_count or cache_retention_duration to cap growth'
196
+ end
197
+
198
+ private
199
+
200
+ # Internal method on the tracer pipeline.
201
+ # @api private
202
+ def blank?(value)
203
+ value.nil? || value.to_s.empty?
204
+ end
205
+
206
+ # Internal method on the tracer pipeline.
207
+ # @api private
208
+ def validate_required!(**opts)
209
+ opts.each do |key, value|
210
+ raise LocalFsBackendError, "#{key} is required" if blank?(value)
211
+ end
212
+ end
213
+
214
+ # Internal method on the tracer pipeline.
215
+ # @api private
216
+ def normalize_test_suite_id(raw)
217
+ return nil if raw.nil?
218
+
219
+ value = raw.to_s
220
+ value.empty? ? nil : value
221
+ end
222
+
223
+ # Internal method on the tracer pipeline.
224
+ # @api private
225
+ def pr_tier?
226
+ @branch != @default_branch
227
+ end
228
+
229
+ # Internal method on the tracer pipeline.
230
+ # @api private
231
+ def own_tier_prefix
232
+ pr_tier? ? "#{PR_TIER}/#{@branch}" : MAIN_TIER
233
+ end
234
+
235
+ # Internal method on the tracer pipeline.
236
+ # @api private
237
+ def main_tier_prefix
238
+ MAIN_TIER
239
+ end
240
+
241
+ # Internal method on the tracer pipeline.
242
+ # @api private
243
+ def archive_path(tier_prefix, ref)
244
+ File.join(*[@root, tier_prefix, ref, @test_suite_id, CACHE_ARCHIVE_FILENAME].compact)
245
+ end
246
+
247
+ # Internal method on the tracer pipeline.
248
+ # @api private
249
+ def ref_dir(tier_prefix, ref)
250
+ File.join(@root, tier_prefix, ref)
251
+ end
252
+
253
+ # Internal method on the tracer pipeline.
254
+ # @api private
255
+ def tier_dir(tier_prefix)
256
+ File.join(@root, tier_prefix)
257
+ end
258
+
259
+ # Internal method on the tracer pipeline.
260
+ # @api private
261
+ def branch_refs_path(branch_name)
262
+ File.join(@root, PR_TIER, branch_name.chomp, BRANCH_REFS_FILENAME)
263
+ end
264
+
265
+ # Internal method on the tracer pipeline.
266
+ # @api private
267
+ def local_last_run_path
268
+ File.join(@cache_path, LAST_RUN_FILENAME)
269
+ end
270
+
271
+ # Internal method on the tracer pipeline.
272
+ # @api private
273
+ def local_run_dir(run_id)
274
+ File.join(@cache_path, run_id)
275
+ end
276
+
277
+ # Internal method on the tracer pipeline.
278
+ # @api private
279
+ def read_local_run_id
280
+ return nil unless File.file?(local_last_run_path)
281
+
282
+ manifest = JSON.parse(File.read(local_last_run_path, encoding: ENCODING))
283
+ return nil unless manifest.is_a?(Hash)
284
+
285
+ run_id = manifest['run_id']
286
+ return nil if blank?(run_id)
287
+
288
+ run_id
289
+ rescue StandardError
290
+ nil
291
+ end
292
+
293
+ # Attempt extraction from (tier_prefix, ref). Returns true on
294
+ # validated success. Rolls back any partially-extracted state on
295
+ # failure so the next caller doesn't observe half-landed cache.
296
+ # rubocop:disable Naming/PredicateMethod
297
+ def try_download_from(tier_prefix, ref)
298
+ src = archive_path(tier_prefix, ref)
299
+ return false unless File.file?(src)
300
+
301
+ extract_and_validate(src, tier_prefix, ref)
302
+ end
303
+
304
+ # Internal method on the tracer pipeline.
305
+ # @api private
306
+ def extract_and_validate(src, tier_prefix, ref)
307
+ begin
308
+ Archive.extract(archive_path: src, dest_dir: @cache_path)
309
+ rescue StandardError => e
310
+ log_debug("extract failed for #{tier_prefix}/#{ref}: #{e.class}: #{e.message}")
311
+ rollback_extracted_cache
312
+ return false
313
+ end
314
+
315
+ return true if Validator.valid_file?(local_last_run_path)
316
+
317
+ log_debug("rejected #{tier_prefix}/#{ref}: schema_version mismatch")
318
+ rollback_extracted_cache
319
+ false
320
+ end
321
+ # rubocop:enable Naming/PredicateMethod
322
+
323
+ def rollback_extracted_cache
324
+ run_id = read_local_run_id
325
+ FileUtils.rm_f(local_last_run_path)
326
+ FileUtils.rm_rf(local_run_dir(run_id)) if run_id
327
+ end
328
+
329
+ # Enumerate refs in `tier_prefix` as Array<[ref, mtime_epoch]>,
330
+ # newest-first. Uses the archive's mtime as the timestamp proxy -
331
+ # a fresh upload overwrites the archive via atomic rename so
332
+ # mtime tracks upload time correctly.
333
+ def list_refs_in_tier(tier_prefix)
334
+ dir = tier_dir(tier_prefix)
335
+ return [] unless File.directory?(dir)
336
+
337
+ refs = []
338
+ Dir.each_child(dir) do |ref|
339
+ archive = archive_path(tier_prefix, ref)
340
+ next unless File.file?(archive)
341
+
342
+ refs << [ref, File.mtime(archive).to_i]
343
+ end
344
+ refs.sort_by { |_, ts| -ts }
345
+ end
346
+
347
+ # Internal method on the tracer pipeline.
348
+ # @api private
349
+ def prune_by_count!(count)
350
+ refs = list_refs_in_tier(own_tier_prefix)
351
+ return 0 if refs.length <= count
352
+
353
+ to_delete = refs[count..] || []
354
+ delete_refs(to_delete.map(&:first), own_tier_prefix)
355
+ end
356
+
357
+ # Internal method on the tracer pipeline.
358
+ # @api private
359
+ def prune_by_duration!(duration_seconds)
360
+ cutoff = Time.now.to_i - duration_seconds.to_i
361
+ stale = list_refs_in_tier(own_tier_prefix).select { |_, ts| ts < cutoff }.map(&:first)
362
+ delete_refs(stale, own_tier_prefix)
363
+ end
364
+
365
+ # Internal method on the tracer pipeline.
366
+ # @api private
367
+ def delete_refs(refs, tier_prefix)
368
+ removed = 0
369
+ refs.each do |ref|
370
+ FileUtils.rm_rf(ref_dir(tier_prefix, ref))
371
+ removed += 1
372
+ log_debug("pruned ref #{tier_prefix}/#{ref}")
373
+ rescue StandardError => e
374
+ log_warn("failed to prune ref #{tier_prefix}/#{ref}: #{e.class}: #{e.message}")
375
+ end
376
+ removed
377
+ end
378
+
379
+ # Internal method on the tracer pipeline.
380
+ # @api private
381
+ def prune_dead_pr_branch!(ttl_seconds)
382
+ refs = list_refs_in_tier(own_tier_prefix)
383
+ return 0 if refs.empty?
384
+
385
+ newest_ts = refs.first[1]
386
+ return 0 if newest_ts >= Time.now.to_i - ttl_seconds.to_i
387
+
388
+ delete_branch_prefix(own_tier_prefix, refs.length)
389
+ end
390
+
391
+ # Delete every descendant of `<root>/<tier_prefix>` (cache dirs +
392
+ # branch_refs.json). Returns the supplied `ref_count` on success,
393
+ # 0 on failure.
394
+ def delete_branch_prefix(tier_prefix, ref_count)
395
+ FileUtils.rm_rf(tier_dir(tier_prefix))
396
+ log_debug("pruned dead PR branch #{tier_prefix}")
397
+ ref_count
398
+ rescue StandardError => e
399
+ log_warn("failed to prune dead PR branch #{tier_prefix}: #{e.class}: #{e.message}")
400
+ 0
401
+ end
402
+
403
+ # Internal method on the tracer pipeline.
404
+ # @api private
405
+ def branch_dirs(pr_root)
406
+ Dir.each_child(pr_root)
407
+ .map { |name| File.join(pr_root, name) }
408
+ .select { |path| File.directory?(path) }
409
+ end
410
+
411
+ # Apply the TTL to a single PR branch dir. Deletes whole branch
412
+ # when its newest ref is older than cutoff. Returns the count of
413
+ # refs removed (0 when branch is alive).
414
+ def maybe_prune_branch(branch_dir, cutoff)
415
+ branch_name = File.basename(branch_dir)
416
+ tier_prefix = "#{PR_TIER}/#{branch_name}"
417
+ refs = list_refs_in_tier(tier_prefix)
418
+ return 0 if refs.empty?
419
+
420
+ newest_ts = refs.first[1]
421
+ return 0 if newest_ts >= cutoff
422
+
423
+ delete_branch_prefix(tier_prefix, refs.length)
424
+ end
425
+
426
+ # Internal method on the tracer pipeline.
427
+ # @api private
428
+ def log_debug(message)
429
+ @logger&.debug("rspec-tracer remote_cache: #{message}")
430
+ end
431
+
432
+ # Internal method on the tracer pipeline.
433
+ # @api private
434
+ def log_warn(message)
435
+ @logger&.warn("rspec-tracer remote_cache: #{message}")
436
+ end
437
+ end
438
+ end
439
+ end