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,712 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'securerandom'
6
+ require 'set'
7
+ require 'time'
8
+ require 'tmpdir'
9
+
10
+ require_relative 'archive'
11
+ require_relative 'backend'
12
+ require_relative 'validator'
13
+
14
+ module RSpecTracer
15
+ # Internal RemoteCache — see {RSpecTracer} for the user-facing surface.
16
+ # @api private
17
+ module RemoteCache
18
+ # S3 implementation of `RemoteCache::Backend`. Shells out to the
19
+ # `aws` / `awslocal` CLI for every operation - matches 1.x's
20
+ # behavior and avoids pulling `aws-sdk-s3` into the gem's runtime
21
+ # deps. Users on 1.x already have `aws` on PATH per the documented
22
+ # CI recipe; 2.0 asks nothing new.
23
+ #
24
+ # Two-tier S3 layout (change from 1.x flat layout; paired with the
25
+ # schema_version bump - one cold run on upgrade). Cache payload is
26
+ # a single `cache.tar.gz` per ref (~15 JSON files + last_run.json
27
+ # packed together; ~4-6x smaller on the wire + 1 GET per download
28
+ # instead of 15):
29
+ #
30
+ # s3://<bucket>/<prefix>/
31
+ # main/<sha>/[<test_suite_id>/]cache.tar.gz
32
+ # pr/<branch>/<sha>/[<test_suite_id>/]cache.tar.gz
33
+ # pr/<branch>/branch_refs.json
34
+ #
35
+ # Local cache_path layout is unchanged - the archive is a transit
36
+ # boundary only. Users and external tooling continue to see the
37
+ # 15-file disk layout documented in `USER_FACING_SURFACE.md` section 6.
38
+ #
39
+ # Tier is determined from `branch` vs `default_branch` at construction.
40
+ # Main-branch builds write to main tier; PR builds write to their
41
+ # branch-scoped pr tier. Download tries the backend's own tier first,
42
+ # then falls back to main tier for the same ref (catches PRs
43
+ # cherry-picking from main).
44
+ #
45
+ # Retention (closes issue #20 at the architectural layer, not just
46
+ # with a knob):
47
+ # - `cache_retention_count N` keeps newest N refs per tier
48
+ # (main has N refs, each PR branch has N refs).
49
+ # - `cache_retention_duration_seconds X` prunes refs older than
50
+ # X seconds in any tier the backend visits.
51
+ # - `cache_retention_pr_branch_ttl_seconds X` deletes a PR branch
52
+ # entirely (including its branch_refs.json) when no ref has
53
+ # been touched in X seconds. Applied at upload time in the
54
+ # backend's own branch only; cross-branch cleanup is a separate
55
+ # Rake task.
56
+ #
57
+ # Graceful-degradation contract:
58
+ # - `download` returns false and never raises on wire/validation
59
+ # failure. Partial downloads are cleaned up.
60
+ # - `upload` raises on wire failure; the Rake task catches.
61
+ # - `branch_refs` returns `{}` on missing file.
62
+ # - `prune!` returns count removed, never raises.
63
+ #
64
+ # S3 shells out via `aws` CLI - a single class is the natural unit
65
+ # of composition here. The class is large; splitting would be
66
+ # cosmetic.
67
+ # rubocop:disable Metrics/ClassLength
68
+ class S3Backend
69
+ # Internal S3BackendError — see {RSpecTracer} for the user-facing surface.
70
+ # @api private
71
+ class S3BackendError < StandardError; end
72
+
73
+ # Internal constant.
74
+ # @api private
75
+ MAIN_TIER = 'main'
76
+ # Internal constant.
77
+ # @api private
78
+ PR_TIER = 'pr'
79
+ # Internal constant.
80
+ # @api private
81
+ BRANCH_REFS_FILENAME = 'branch_refs.json'
82
+ # Internal constant.
83
+ # @api private
84
+ LAST_RUN_FILENAME = 'last_run.json'
85
+ # Internal constant.
86
+ # @api private
87
+ CACHE_ARCHIVE_FILENAME = Archive::CACHE_FILENAME
88
+ # Internal constant.
89
+ # @api private
90
+ ENCODING = 'UTF-8'
91
+
92
+ # Internal constant.
93
+ # @api private
94
+ REQUIRED_OPTS = %i[bucket prefix branch default_branch cache_path].freeze
95
+
96
+ # rubocop:disable Metrics/ParameterLists
97
+ def initialize(bucket:, prefix:, branch:, default_branch:,
98
+ cache_path:, test_suite_id: nil, local: false, logger: nil)
99
+ validate_required!(bucket: bucket, prefix: prefix, branch: branch,
100
+ default_branch: default_branch, cache_path: cache_path)
101
+
102
+ @bucket = bucket.to_s
103
+ @prefix = trim_trailing_slashes(prefix.to_s)
104
+ @branch = branch.to_s.chomp
105
+ @default_branch = default_branch.to_s.chomp
106
+ @test_suite_id = normalize_test_suite_id(test_suite_id)
107
+ @cache_path = cache_path.to_s
108
+ @cli_binary = local ? 'awslocal' : 'aws'
109
+ @logger = logger
110
+ end
111
+ # rubocop:enable Metrics/ParameterLists
112
+
113
+ # Download the cache for `ref` into `cache_path`. Tries the
114
+ # backend's own tier first; on miss, falls back to the main tier
115
+ # for the same ref. Validates the downloaded `last_run.json` via
116
+ # schema_version before declaring success.
117
+ #
118
+ # When `tree_sha` is provided, first consults the tree-SHA
119
+ # secondary index (`<tier>/by_tree/<tree_sha>`) to resolve the
120
+ # tree to a commit ref - catches rebase / revert scenarios where
121
+ # the same tree lives at a different commit hash than the one
122
+ # the caller is asking about. The standard `<tier>/<ref>` lookup
123
+ # is still tried as a fallback when the tree pointer is absent
124
+ # or its resolved ref has no archive.
125
+ #
126
+ # Returns true on validated success, false on any failure. Cleans
127
+ # up partially-downloaded files on failure so a subsequent fresh
128
+ # load doesn't see stale data.
129
+ def download(ref, tree_sha: nil)
130
+ return false if ref.nil? || ref.to_s.empty?
131
+
132
+ attempts = build_download_attempts(ref, tree_sha)
133
+ attempts.any? { |tier, candidate| try_download_from(tier, candidate) }
134
+ end
135
+
136
+ # Upload the local cache to this backend's own tier under `ref`.
137
+ # Packs the 15-file local layout into a single `cache.tar.gz` and
138
+ # uploads that one object. Raises on wire failure. Idempotent.
139
+ #
140
+ # When `tree_sha` is provided, ALSO writes a small pointer file
141
+ # at `<tier>/by_tree/<tree_sha>` containing the commit-SHA. The
142
+ # pointer is consumed by `download(ref, tree_sha: ...)` to hit
143
+ # the cache when a different commit (rebase / revert) shares
144
+ # the same tree.
145
+ def upload(ref, tree_sha: nil)
146
+ raise S3BackendError, 'ref is required' if blank?(ref)
147
+
148
+ run_id = read_local_run_id
149
+ raise S3BackendError, "no local cache to upload (missing #{LAST_RUN_FILENAME})" if run_id.nil?
150
+
151
+ archive_path = tmp_archive_path('upload')
152
+ begin
153
+ Archive.pack(cache_path: @cache_path, run_id: run_id, dest_path: archive_path)
154
+ upload_file(archive_path, s3_archive_key(own_tier_prefix, ref))
155
+ upload_tree_pointer(ref, tree_sha) unless blank?(tree_sha)
156
+ log_debug("uploaded cache for #{ref} to #{own_tier_prefix} (#{File.size(archive_path)} bytes)")
157
+ ensure
158
+ FileUtils.rm_f(archive_path)
159
+ end
160
+ end
161
+
162
+ # Read branch_refs for the given branch. Returns `{sha => ts_epoch}`
163
+ # or `{}` when the file is missing / malformed. PR tier only -
164
+ # main branch doesn't track branch_refs (rewrites not expected on
165
+ # the default branch).
166
+ def branch_refs(branch_name)
167
+ return {} if blank?(branch_name)
168
+
169
+ local_tmp = File.join(@cache_path, ".branch_refs_download_#{Process.pid}.json")
170
+ FileUtils.mkdir_p(@cache_path)
171
+
172
+ ok, = aws_cp_silent(s3_url(s3_branch_refs_key(branch_name)), local_tmp)
173
+ return {} unless ok
174
+
175
+ parsed = JSON.parse(File.read(local_tmp, encoding: ENCODING))
176
+ parsed.is_a?(Hash) ? parsed.transform_values(&:to_i) : {}
177
+ rescue StandardError => e
178
+ log_debug("branch_refs read failed (#{e.class}: #{e.message}); treating as empty")
179
+ {}
180
+ ensure
181
+ FileUtils.rm_f(local_tmp) if defined?(local_tmp) && local_tmp
182
+ end
183
+
184
+ # Persist branch_refs for the given branch. No-op for main-branch
185
+ # writes (main-branch doesn't use branch_refs). Raises on wire
186
+ # failure for PR tier.
187
+ def write_branch_refs(branch_name, refs)
188
+ return if blank?(branch_name)
189
+ return if branch_name.to_s.chomp == @default_branch
190
+ return if refs.nil? || refs.empty?
191
+
192
+ FileUtils.mkdir_p(@cache_path)
193
+ local_tmp = File.join(@cache_path, ".branch_refs_upload_#{Process.pid}.json")
194
+ File.write(local_tmp, JSON.pretty_generate(refs), encoding: ENCODING)
195
+
196
+ ok, _stdout, stderr = aws_cp_silent(local_tmp, s3_url(s3_branch_refs_key(branch_name)))
197
+ raise S3BackendError, "Failed to upload branch_refs for #{branch_name}: #{stderr.chomp}" unless ok
198
+
199
+ log_debug("wrote branch_refs for #{branch_name}")
200
+ ensure
201
+ FileUtils.rm_f(local_tmp) if defined?(local_tmp) && local_tmp
202
+ end
203
+
204
+ # Apply retention policy to the backend's own tier. Returns the
205
+ # number of refs removed. Never raises on a partial failure; logs
206
+ # and returns the count it managed to delete.
207
+ #
208
+ # Semantics:
209
+ # - count N: keep newest N refs, delete older.
210
+ # - duration_seconds X: delete refs whose last_run.json is
211
+ # older than X seconds.
212
+ # - pr_branch_ttl_seconds X: (PR tier only) if the backend's
213
+ # branch has no ref newer than X seconds, delete the entire
214
+ # pr/<branch>/ prefix (branch_refs.json included).
215
+ #
216
+ # Two or more parameters may be set; each applies independently.
217
+ # All nil/0 => no-op.
218
+ def prune!(count: nil, duration_seconds: nil, pr_branch_ttl_seconds: nil)
219
+ removed = 0
220
+ removed += prune_by_count!(count) if count&.positive?
221
+ removed += prune_by_duration!(duration_seconds) if duration_seconds&.positive?
222
+ removed += prune_dead_pr_branch!(pr_branch_ttl_seconds) if pr_tier? && pr_branch_ttl_seconds&.positive?
223
+ removed
224
+ end
225
+
226
+ # Cross-tier PR-branch cleanup. Enumerates every PR branch under
227
+ # the configured prefix by listing the `pr/` subtree, applies the
228
+ # TTL to each branch, deletes dead branches whole. Returns total
229
+ # refs removed. No-op on nil / non-positive TTL. Never raises
230
+ # (graceful-degradation contract).
231
+ def prune_all!(pr_branch_ttl_seconds: nil)
232
+ return 0 unless pr_branch_ttl_seconds&.positive?
233
+
234
+ cutoff = Time.now.to_i - pr_branch_ttl_seconds.to_i
235
+ branches = discover_pr_branches
236
+ branches.sum { |branch| maybe_prune_branch(branch, cutoff) }
237
+ rescue StandardError => e
238
+ log_warn("prune_all! failed (#{e.class}: #{e.message})")
239
+ 0
240
+ end
241
+
242
+ # Check whether the backend's own tier has accumulated more than
243
+ # `warn_threshold` refs without retention configured. Callable
244
+ # from orchestrator for the "S3 growing unbounded" diagnostic.
245
+ def unbounded_warning(warn_threshold: 500)
246
+ refs = list_own_tier_refs
247
+ return nil unless refs.length > warn_threshold
248
+
249
+ "rspec-tracer remote cache has #{refs.length} refs in #{own_tier_prefix}; " \
250
+ 'configure cache_retention_count or cache_retention_duration to cap growth'
251
+ end
252
+
253
+ private
254
+
255
+ # Internal method on the tracer pipeline.
256
+ # @api private
257
+ def blank?(value)
258
+ value.nil? || value.to_s.empty?
259
+ end
260
+
261
+ # Non-regex trailing-slash strip. The literal `/+\z` pattern trips
262
+ # CodeQL's `rb/polynomial-redos` heuristic because quantifier-on-
263
+ # library-input is a conservative-fail signal; the pattern is
264
+ # backtracking-safe in practice, but String#chop in a loop is
265
+ # both obviously safe and faster on short inputs.
266
+ def trim_trailing_slashes(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 validate_required!(**opts)
275
+ opts.each do |key, value|
276
+ raise S3BackendError, "#{key} is required" if blank?(value)
277
+ end
278
+ end
279
+
280
+ # Internal method on the tracer pipeline.
281
+ # @api private
282
+ def normalize_test_suite_id(raw)
283
+ return nil if raw.nil?
284
+
285
+ value = raw.to_s
286
+ value.empty? ? nil : value
287
+ end
288
+
289
+ # -- Tier + key composition -------------------------
290
+
291
+ def pr_tier?
292
+ @branch != @default_branch
293
+ end
294
+
295
+ # Internal method on the tracer pipeline.
296
+ # @api private
297
+ def own_tier_prefix
298
+ pr_tier? ? "#{PR_TIER}/#{@branch}" : MAIN_TIER
299
+ end
300
+
301
+ # Internal method on the tracer pipeline.
302
+ # @api private
303
+ def main_tier_prefix
304
+ MAIN_TIER
305
+ end
306
+
307
+ # Internal method on the tracer pipeline.
308
+ # @api private
309
+ def s3_url(key)
310
+ "s3://#{@bucket}/#{key}"
311
+ end
312
+
313
+ # Internal method on the tracer pipeline.
314
+ # @api private
315
+ def s3_archive_key(tier_prefix, ref)
316
+ join_key(@prefix, tier_prefix, ref, @test_suite_id, CACHE_ARCHIVE_FILENAME)
317
+ end
318
+
319
+ # Internal method on the tracer pipeline.
320
+ # @api private
321
+ def s3_tree_pointer_key(tier_prefix, tree_sha)
322
+ join_key(@prefix, tier_prefix, 'by_tree', tree_sha)
323
+ end
324
+
325
+ # Internal method on the tracer pipeline.
326
+ # @api private
327
+ def s3_branch_refs_key(branch_name)
328
+ join_key(@prefix, PR_TIER, branch_name.chomp, BRANCH_REFS_FILENAME)
329
+ end
330
+
331
+ # Internal method on the tracer pipeline.
332
+ # @api private
333
+ def s3_ref_prefix_url(tier_prefix, ref)
334
+ "#{s3_url(join_key(@prefix, tier_prefix, ref))}/"
335
+ end
336
+
337
+ # Internal method on the tracer pipeline.
338
+ # @api private
339
+ def s3_tier_prefix_url(tier_prefix)
340
+ "#{s3_url(join_key(@prefix, tier_prefix))}/"
341
+ end
342
+
343
+ # Internal method on the tracer pipeline.
344
+ # @api private
345
+ def join_key(*segments)
346
+ segments.compact.reject { |s| s.to_s.empty? }.join('/')
347
+ end
348
+
349
+ # -- Local-side paths -------------------------------
350
+
351
+ def local_last_run_path
352
+ File.join(@cache_path, LAST_RUN_FILENAME)
353
+ end
354
+
355
+ # Internal method on the tracer pipeline.
356
+ # @api private
357
+ def local_run_dir(run_id)
358
+ File.join(@cache_path, run_id)
359
+ end
360
+
361
+ # Internal method on the tracer pipeline.
362
+ # @api private
363
+ def read_local_run_id
364
+ return nil unless File.file?(local_last_run_path)
365
+
366
+ manifest = JSON.parse(File.read(local_last_run_path, encoding: ENCODING))
367
+ return nil unless manifest.is_a?(Hash)
368
+
369
+ run_id = manifest['run_id']
370
+ return nil if run_id.nil? || run_id.to_s.empty?
371
+
372
+ run_id
373
+ rescue StandardError
374
+ nil
375
+ end
376
+
377
+ # -- Tree-SHA secondary index -----------------------
378
+
379
+ # Build the list of (tier_prefix, ref) pairs to try, in
380
+ # priority order:
381
+ # 1. Tree-pointer-resolved ref on own tier (rebase hit)
382
+ # 2. Tree-pointer-resolved ref on main tier (PR backends only)
383
+ # 3. Direct commit ref on own tier (standard path)
384
+ # 4. Direct commit ref on main tier (PR backends only)
385
+ # Tree-pointer attempts are only added when (a) tree_sha is
386
+ # given AND (b) the pointer file resolves to a non-empty ref.
387
+ def build_download_attempts(ref, tree_sha)
388
+ attempts = []
389
+ tree_resolved = resolve_tree_pointer(tree_sha)
390
+ if tree_resolved
391
+ attempts << [own_tier_prefix, tree_resolved]
392
+ attempts << [main_tier_prefix, tree_resolved] if pr_tier?
393
+ end
394
+ attempts << [own_tier_prefix, ref]
395
+ attempts << [main_tier_prefix, ref] if pr_tier?
396
+ attempts
397
+ end
398
+
399
+ # Read the tree pointer for `tree_sha` from the backend's own
400
+ # tier. Returns the resolved commit-SHA on hit, nil on miss /
401
+ # malformed content / wire error. Only the own-tier pointer is
402
+ # consulted - main-tier tree pointers from a parallel upload
403
+ # are not authoritative for a PR backend.
404
+ def resolve_tree_pointer(tree_sha)
405
+ return nil if blank?(tree_sha)
406
+
407
+ local_tmp = File.join(@cache_path, ".tree_pointer_download_#{Process.pid}_#{SecureRandom.hex(4)}.txt")
408
+ FileUtils.mkdir_p(@cache_path)
409
+
410
+ ok, = aws_cp_silent(s3_url(s3_tree_pointer_key(own_tier_prefix, tree_sha)), local_tmp)
411
+ return nil unless ok
412
+
413
+ resolved = File.read(local_tmp, encoding: ENCODING).strip
414
+ resolved.empty? ? nil : resolved
415
+ rescue StandardError => e
416
+ log_debug("tree pointer read failed (#{e.class}: #{e.message}); falling through")
417
+ nil
418
+ ensure
419
+ FileUtils.rm_f(local_tmp) if defined?(local_tmp) && local_tmp
420
+ end
421
+
422
+ # Internal method on the tracer pipeline.
423
+ # @api private
424
+ def upload_tree_pointer(ref, tree_sha)
425
+ pointer_path = File.join(@cache_path, ".tree_pointer_upload_#{Process.pid}_#{SecureRandom.hex(4)}.txt")
426
+ File.write(pointer_path, ref.to_s, encoding: ENCODING)
427
+ upload_file(pointer_path, s3_tree_pointer_key(own_tier_prefix, tree_sha))
428
+ log_debug("wrote tree pointer #{tree_sha} -> #{ref} on #{own_tier_prefix}")
429
+ ensure
430
+ FileUtils.rm_f(pointer_path) if defined?(pointer_path) && pointer_path
431
+ end
432
+
433
+ # -- Download flow ----------------------------------
434
+
435
+ # Download the archive for (tier, ref), extract into cache_path,
436
+ # validate the resulting last_run.json. Returns true on validated
437
+ # success, false otherwise; rolls back extracted files on failure
438
+ # so a later reader never sees a half-landed cache. Action-style
439
+ # method (writes files + cleans up), not a predicate.
440
+ # rubocop:disable Naming/PredicateMethod
441
+ def try_download_from(tier_prefix, ref)
442
+ archive_path = tmp_archive_path('download')
443
+ ok, = aws_cp_silent(s3_url(s3_archive_key(tier_prefix, ref)), archive_path)
444
+ return false unless ok
445
+
446
+ extract_and_validate(archive_path, tier_prefix, ref)
447
+ ensure
448
+ FileUtils.rm_f(archive_path) if defined?(archive_path) && archive_path
449
+ end
450
+
451
+ # Internal method on the tracer pipeline.
452
+ # @api private
453
+ def extract_and_validate(archive_path, tier_prefix, ref)
454
+ begin
455
+ Archive.extract(archive_path: archive_path, dest_dir: @cache_path)
456
+ rescue StandardError => e
457
+ log_debug("extract failed for #{tier_prefix}/#{ref}: #{e.class}: #{e.message}")
458
+ rollback_extracted_cache
459
+ return false
460
+ end
461
+
462
+ return true if Validator.valid_file?(local_last_run_path)
463
+
464
+ log_debug("rejected #{tier_prefix}/#{ref}: schema_version mismatch")
465
+ rollback_extracted_cache
466
+ false
467
+ end
468
+ # rubocop:enable Naming/PredicateMethod
469
+
470
+ def rollback_extracted_cache
471
+ run_id = read_local_run_id
472
+ FileUtils.rm_f(local_last_run_path)
473
+ FileUtils.rm_rf(local_run_dir(run_id)) if run_id
474
+ end
475
+
476
+ # Internal method on the tracer pipeline.
477
+ # @api private
478
+ def tmp_archive_path(purpose)
479
+ FileUtils.mkdir_p(@cache_path)
480
+ File.join(@cache_path, ".cache_#{purpose}_#{Process.pid}_#{SecureRandom.hex(4)}.tar.gz")
481
+ end
482
+
483
+ # -- Upload flow ------------------------------------
484
+
485
+ def upload_file(local_path, s3_key)
486
+ ok, _stdout, stderr = aws_cp_silent(local_path, s3_url(s3_key))
487
+ raise S3BackendError, "Failed to upload #{local_path}: #{stderr.chomp}" unless ok
488
+ end
489
+
490
+ # -- Retention --------------------------------------
491
+
492
+ # List refs under the backend's own tier with their last_run.json
493
+ # LastModified. Returns Array<[ref, epoch_timestamp]>, newest first.
494
+ def list_own_tier_refs
495
+ list_refs_in_tier(own_tier_prefix)
496
+ end
497
+
498
+ # Internal method on the tracer pipeline.
499
+ # @api private
500
+ def list_refs_in_tier(tier_prefix)
501
+ entries = list_objects(join_key(@prefix, tier_prefix))
502
+ return [] if entries.empty?
503
+
504
+ refs = {}
505
+ entries.each do |entry|
506
+ key = entry['Key']
507
+ next unless key.end_with?("/#{CACHE_ARCHIVE_FILENAME}")
508
+
509
+ ref = extract_ref_from_archive_key(key, tier_prefix)
510
+ next if ref.nil?
511
+
512
+ ts = parse_s3_timestamp(entry['LastModified'])
513
+ existing = refs[ref]
514
+ refs[ref] = ts if existing.nil? || ts > existing
515
+ end
516
+ refs.sort_by { |_, ts| -ts }
517
+ end
518
+
519
+ # Keys look like: <prefix>/<tier_prefix>/<ref>/[<test_suite_id>/]cache.tar.gz
520
+ # We want <ref>.
521
+ def extract_ref_from_archive_key(key, tier_prefix)
522
+ tier_head = "#{join_key(@prefix, tier_prefix)}/"
523
+ return nil unless key.start_with?(tier_head)
524
+
525
+ remainder = key[tier_head.length..]
526
+ segments = remainder.split('/')
527
+ return nil if segments.length < 2
528
+
529
+ segments.first
530
+ end
531
+
532
+ # Internal method on the tracer pipeline.
533
+ # @api private
534
+ def prune_by_count!(count)
535
+ refs = list_own_tier_refs
536
+ return 0 if refs.length <= count
537
+
538
+ to_delete = refs[count..] || []
539
+ delete_refs(to_delete.map(&:first))
540
+ end
541
+
542
+ # Internal method on the tracer pipeline.
543
+ # @api private
544
+ def prune_by_duration!(duration_seconds)
545
+ cutoff = Time.now.to_i - duration_seconds.to_i
546
+ stale = list_own_tier_refs.select { |_, ts| ts < cutoff }.map(&:first)
547
+ delete_refs(stale)
548
+ end
549
+
550
+ # Internal method on the tracer pipeline.
551
+ # @api private
552
+ def delete_refs(refs)
553
+ removed = 0
554
+ refs.each do |ref|
555
+ ok, _stdout, stderr = aws_rm_recursive_silent(s3_ref_prefix_url(own_tier_prefix, ref))
556
+ if ok
557
+ removed += 1
558
+ log_debug("pruned ref #{own_tier_prefix}/#{ref}")
559
+ else
560
+ log_warn("failed to prune ref #{own_tier_prefix}/#{ref}: #{stderr.chomp}")
561
+ end
562
+ end
563
+ removed
564
+ end
565
+
566
+ # Internal method on the tracer pipeline.
567
+ # @api private
568
+ def prune_dead_pr_branch!(ttl_seconds)
569
+ refs = list_own_tier_refs
570
+ return 0 if refs.empty?
571
+
572
+ newest_ts = refs.first[1]
573
+ return 0 if newest_ts >= Time.now.to_i - ttl_seconds.to_i
574
+
575
+ delete_branch_prefix(own_tier_prefix, refs.length)
576
+ end
577
+
578
+ # Delete every object under `<prefix>/<tier_prefix>/` (cache refs
579
+ # + branch_refs.json). Returns the supplied `ref_count` on
580
+ # success, 0 on failure.
581
+ def delete_branch_prefix(tier_prefix, ref_count)
582
+ ok, _stdout, stderr = aws_rm_recursive_silent(s3_tier_prefix_url(tier_prefix))
583
+ if ok
584
+ log_debug("pruned dead PR branch #{tier_prefix} (#{ref_count} refs)")
585
+ ref_count
586
+ else
587
+ log_warn("failed to prune dead PR branch #{tier_prefix}: #{stderr.chomp}")
588
+ 0
589
+ end
590
+ end
591
+
592
+ # Enumerate PR branches under the configured prefix. Returns an
593
+ # Array<String> of branch names (one per unique `pr/<branch>/`
594
+ # segment). Uses `list-objects-v2 --prefix pr/ --delimiter /` so
595
+ # we pay for one bucket listing instead of walking every object.
596
+ def discover_pr_branches
597
+ prefix_head = "#{join_key(@prefix, PR_TIER)}/"
598
+ common_prefixes = list_common_prefixes(prefix_head)
599
+ return [] if common_prefixes.empty?
600
+
601
+ branches = Set.new
602
+ common_prefixes.each do |entry|
603
+ value = entry['Prefix']
604
+ next if value.nil? || !value.start_with?(prefix_head)
605
+
606
+ branch = value[prefix_head.length..].delete_suffix('/')
607
+ branches << branch unless branch.empty?
608
+ end
609
+ branches.to_a
610
+ end
611
+
612
+ # Internal method on the tracer pipeline.
613
+ # @api private
614
+ def list_common_prefixes(prefix)
615
+ stdout, stderr, status = Open3.capture3(
616
+ @cli_binary, 's3api', 'list-objects-v2',
617
+ '--bucket', @bucket,
618
+ '--prefix', prefix,
619
+ '--delimiter', '/',
620
+ '--output', 'json'
621
+ )
622
+ unless status.success?
623
+ log_debug("list-objects-v2 (delimited) #{prefix} failed: #{stderr.chomp}")
624
+ return []
625
+ end
626
+ return [] if stdout.strip.empty?
627
+
628
+ parsed = JSON.parse(stdout)
629
+ Array(parsed['CommonPrefixes'])
630
+ rescue StandardError => e
631
+ log_debug("list-objects-v2 (delimited) parse failed: #{e.class}: #{e.message}")
632
+ []
633
+ end
634
+
635
+ # Apply the TTL to a single PR branch. Deletes whole branch when
636
+ # its newest ref is older than `cutoff`. Returns the count of
637
+ # refs removed (0 when branch is alive).
638
+ def maybe_prune_branch(branch_name, cutoff)
639
+ tier_prefix = "#{PR_TIER}/#{branch_name}"
640
+ refs = list_refs_in_tier(tier_prefix)
641
+ return 0 if refs.empty?
642
+
643
+ newest_ts = refs.first[1]
644
+ return 0 if newest_ts >= cutoff
645
+
646
+ delete_branch_prefix(tier_prefix, refs.length)
647
+ end
648
+
649
+ # Internal method on the tracer pipeline.
650
+ # @api private
651
+ def parse_s3_timestamp(iso_string)
652
+ Time.parse(iso_string).to_i
653
+ rescue StandardError
654
+ 0
655
+ end
656
+
657
+ # -- AWS CLI shell-out ------------------------------
658
+
659
+ def aws_cp_silent(src, dst)
660
+ run_aws('s3', 'cp', src, dst)
661
+ end
662
+
663
+ # Internal method on the tracer pipeline.
664
+ # @api private
665
+ def aws_rm_recursive_silent(dst)
666
+ run_aws('s3', 'rm', dst, '--recursive')
667
+ end
668
+
669
+ # Internal method on the tracer pipeline.
670
+ # @api private
671
+ def list_objects(prefix)
672
+ stdout, stderr, status = Open3.capture3(
673
+ @cli_binary, 's3api', 'list-objects-v2',
674
+ '--bucket', @bucket,
675
+ '--prefix', "#{prefix}/",
676
+ '--output', 'json'
677
+ )
678
+ unless status.success?
679
+ log_debug("list-objects-v2 #{prefix} failed: #{stderr.chomp}")
680
+ return []
681
+ end
682
+ return [] if stdout.strip.empty?
683
+
684
+ parsed = JSON.parse(stdout)
685
+ Array(parsed['Contents'])
686
+ rescue StandardError => e
687
+ log_debug("list-objects-v2 parse failed: #{e.class}: #{e.message}")
688
+ []
689
+ end
690
+
691
+ # Internal method on the tracer pipeline.
692
+ # @api private
693
+ def run_aws(*args)
694
+ stdout, stderr, status = Open3.capture3(@cli_binary, *args)
695
+ [status.success?, stdout, stderr]
696
+ end
697
+
698
+ # -- Logging ----------------------------------------
699
+
700
+ def log_debug(message)
701
+ @logger&.debug("rspec-tracer remote_cache: #{message}")
702
+ end
703
+
704
+ # Internal method on the tracer pipeline.
705
+ # @api private
706
+ def log_warn(message)
707
+ @logger&.warn("rspec-tracer remote_cache: #{message}")
708
+ end
709
+ end
710
+ # rubocop:enable Metrics/ClassLength
711
+ end
712
+ end