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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +197 -45
- data/README.md +439 -429
- data/bin/rspec-tracer +15 -0
- data/lib/rspec_tracer/cache/Rakefile +43 -0
- data/lib/rspec_tracer/cli/cache_clear.rb +98 -0
- data/lib/rspec_tracer/cli/cache_info.rb +103 -0
- data/lib/rspec_tracer/cli/doctor.rb +275 -0
- data/lib/rspec_tracer/cli/explain.rb +148 -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 +1100 -3
- data/lib/rspec_tracer/engine.rb +1076 -0
- data/lib/rspec_tracer/example.rb +21 -6
- 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 +397 -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 +178 -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 +68 -0
- data/lib/rspec_tracer/storage/json_backend.rb +866 -0
- data/lib/rspec_tracer/storage/lazy_snapshot.rb +65 -0
- data/lib/rspec_tracer/storage/schema.rb +43 -0
- data/lib/rspec_tracer/storage/serializer/json.rb +41 -0
- data/lib/rspec_tracer/storage/serializer/msgpack.rb +90 -0
- data/lib/rspec_tracer/storage/snapshot.rb +127 -0
- data/lib/rspec_tracer/storage/sqlite_backend.rb +686 -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 +232 -381
- metadata +93 -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,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
|