Skip to content

Commit 18586aa

Browse files
authored
Merge pull request #177 from fastruby/feature/merge-shards-command
Add deprecations merge command to combine parallel CI shards
2 parents 43b9129 + 03e1128 commit 18586aa

6 files changed

Lines changed: 279 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
- [BUGFIX: example](https://github.com/fastruby/next_rails/pull/<number>)
44

5+
- [FEATURE: Add `deprecations merge` command to combine parallel CI shards](https://github.com/fastruby/next_rails/pull/177)
6+
- [FEATURE: Add parallel CI support for DeprecationTracker](https://github.com/fastruby/next_rails/pull/176)
57
- [CHORE: Add Ruby 4.0 to the test matrix](https://github.com/fastruby/next_rails/pull/178)
68

79
* Your changes/patches go here.

README.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,13 +150,69 @@ DEPRECATION_TRACKER=save rspec
150150
DEPRECATION_TRACKER=compare rspec
151151
```
152152

153+
### Parallel CI support
154+
155+
When running tests across parallel CI nodes, each node can write to its own shard file to avoid conflicts. The tracker auto-detects the node index from common CI environment variables (`CI_NODE_INDEX`, `CIRCLE_NODE_INDEX`, `BUILDKITE_PARALLEL_JOB`, `SEMAPHORE_JOB_INDEX`, `CI_NODE_INDEX` for GitLab), or you can set it explicitly via the `node_index` option.
156+
157+
#### RSpec
158+
159+
```ruby
160+
RSpec.configure do |config|
161+
if ENV["DEPRECATION_TRACKER"]
162+
DeprecationTracker.track_rspec(
163+
config,
164+
node_index: ENV["CI_NODE_INDEX"]
165+
)
166+
end
167+
end
168+
```
169+
170+
When `node_index` is set, the tracker writes to a shard file (e.g. `deprecation_warning.shitlist.node-0.json`) instead of the canonical file.
171+
172+
#### Merging shards
173+
174+
After all parallel nodes finish saving, merge shards into the canonical file:
175+
176+
```bash
177+
# Merge all shard files and remove them afterwards
178+
deprecations merge --delete-shards
179+
180+
# Or use --next to merge shards for the next Rails version
181+
deprecations merge --next --delete-shards
182+
```
183+
184+
You can also merge shards programmatically:
185+
186+
```ruby
187+
DeprecationTracker.merge_shards(
188+
"spec/support/deprecation_warning.shitlist.json",
189+
delete_shards: true
190+
)
191+
```
192+
193+
#### Example CI workflow
194+
195+
```yaml
196+
# 1. Save phase — each parallel node writes its own shard
197+
# (runs on every node)
198+
DEPRECATION_TRACKER=save CI_NODE_INDEX=$NODE bundle exec rspec <subset>
199+
200+
# 2. Merge phase — fan-in step, runs once after all nodes finish
201+
deprecations merge --delete-shards
202+
203+
# 3. Compare phase — each parallel node checks its buckets
204+
# against the merged canonical file
205+
DEPRECATION_TRACKER=compare CI_NODE_INDEX=$NODE bundle exec rspec <subset>
206+
```
207+
153208
### `deprecations` command
154209

155210
View, filter, and manage stored deprecation warnings:
156211

157212
```bash
158213
deprecations info
159214
deprecations info --pattern "ActiveRecord::Base"
215+
deprecations merge --delete-shards
160216
deprecations run
161217
deprecations --help
162218
```

exe/deprecations

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ require "json"
33
require "rainbow"
44
require "optparse"
55
require "set"
6+
require_relative "../lib/deprecation_tracker/shard_merger"
67

78
def run_tests(deprecation_warnings, opts = {})
89
tracker_mode = opts[:tracker_mode]
@@ -49,6 +50,7 @@ option_parser = OptionParser.new do |opts|
4950
bin/deprecations --next info # Show top ten deprecations for Rails 5
5051
bin/deprecations --pattern "ActiveRecord::Base" --verbose info # Show full details on deprecations matching pattern
5152
bin/deprecations --tracker-mode save --pattern "pass" run # Run tests that output deprecations matching pattern and update shitlist
53+
bin/deprecations merge --delete-shards # Merge parallel CI shards and remove shard files
5254
5355
Modes:
5456
info
@@ -57,6 +59,9 @@ option_parser = OptionParser.new do |opts|
5759
run
5860
Run tests that are known to cause deprecation warnings. Use --pattern to filter what tests are run.
5961
62+
merge
63+
Merge parallel CI shard files into the canonical shitlist. Use with --delete-shards to remove shard files after merging.
64+
6065
Options:
6166
MESSAGE
6267

@@ -76,6 +81,10 @@ option_parser = OptionParser.new do |opts|
7681
options[:verbose] = true
7782
end
7883

84+
opts.on("--delete-shards", "Delete shard files after merging") do
85+
options[:delete_shards] = true
86+
end
87+
7988
opts.on_tail("-h", "--help", "Prints this help") do
8089
puts opts
8190
exit
@@ -87,24 +96,34 @@ option_parser.parse!
8796
options[:mode] = ARGV.last
8897
path = options[:next] ? "spec/support/deprecation_warning.next.shitlist.json" : "spec/support/deprecation_warning.shitlist.json"
8998

90-
pattern_string = options.fetch(:pattern, ".+")
91-
pattern = /#{pattern_string}/
92-
93-
deprecation_warnings = JSON.parse(File.read(path)).each_with_object({}) do |(test_file, messages), hash|
94-
filtered_messages = messages.select {|message| message.match(pattern) }
95-
hash[test_file] = filtered_messages if !filtered_messages.empty?
96-
end
99+
case options[:mode]
100+
when "merge"
101+
output = DeprecationTracker::ShardMerger.new(path, delete_shards: !!options[:delete_shards]).merge
102+
shards = output[:shards]
103+
result = output[:result]
104+
total_messages = result.values.map(&:size).reduce(0, :+)
105+
puts "Merged #{shards} shard files into #{path} (#{result.size} buckets, #{total_messages} deprecation messages)"
106+
when "run", "info"
107+
pattern_string = options.fetch(:pattern, ".+")
108+
pattern = /#{pattern_string}/
109+
110+
deprecation_warnings = JSON.parse(File.read(path)).each_with_object({}) do |(test_file, messages), hash|
111+
filtered_messages = messages.select {|message| message.match(pattern) }
112+
hash[test_file] = filtered_messages if !filtered_messages.empty?
113+
end
97114

98-
if deprecation_warnings.empty?
99-
abort "No test files with deprecations matching #{pattern.inspect}."
100-
exit 2
101-
end
115+
if deprecation_warnings.empty?
116+
abort "No test files with deprecations matching #{pattern.inspect}."
117+
exit 2
118+
end
102119

103-
case options.fetch(:mode, "info")
104-
when "run" then run_tests(deprecation_warnings, next_mode: options[:next], tracker_mode: options[:tracker_mode])
105-
when "info" then print_info(deprecation_warnings, verbose: options[:verbose])
120+
if options[:mode] == "run"
121+
run_tests(deprecation_warnings, next_mode: options[:next], tracker_mode: options[:tracker_mode])
122+
else
123+
print_info(deprecation_warnings, verbose: options[:verbose])
124+
end
106125
when nil
107-
STDERR.puts Rainbow("Must pass a mode: run or info").red
126+
STDERR.puts Rainbow("Must pass a mode: run, info, or merge").red
108127
puts option_parser
109128
exit 1
110129
else

lib/deprecation_tracker.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,11 @@ def self.track_minitest(opts = {})
129129
ActiveSupport::TestCase.include(MinitestExtension.new(tracker))
130130
end
131131

132+
def self.merge_shards(base_path, delete_shards: false)
133+
require_relative "deprecation_tracker/shard_merger"
134+
ShardMerger.new(base_path, delete_shards: delete_shards).merge[:result]
135+
end
136+
132137
attr_reader :deprecation_messages, :shitlist_path, :transform_message, :bucket, :mode, :node_index
133138

134139
def initialize(shitlist_path, transform_message = nil, mode = :save, node_index: nil)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
require "json"
2+
require "fileutils"
3+
4+
class DeprecationTracker
5+
class ShardMerger
6+
attr_reader :base_path, :delete_shards
7+
8+
def initialize(base_path, delete_shards: false)
9+
@base_path = base_path
10+
@delete_shards = delete_shards
11+
end
12+
13+
def merge
14+
dirname = File.dirname(base_path)
15+
unless File.directory?(dirname)
16+
warn "Directory does not exist: #{dirname}"
17+
return { shards: 0, result: {} }
18+
end
19+
20+
shard_files = Dir.glob(shard_glob).sort
21+
22+
if shard_files.empty?
23+
warn "No shards found at #{shard_glob}"
24+
return { shards: 0, result: {} }
25+
end
26+
27+
merged = {}
28+
shard_files.each do |file|
29+
parse_shard(file).each do |bucket, messages|
30+
merged[bucket] = (merged[bucket] || []).concat(Array(messages))
31+
end
32+
end
33+
34+
result = {}
35+
merged.sort.each do |k, v|
36+
result[k] = v.sort
37+
end
38+
39+
begin
40+
File.write(base_path, JSON.pretty_generate(result))
41+
rescue Errno::EACCES => e
42+
raise "Cannot write to #{base_path}: #{e.message}"
43+
end
44+
45+
shard_files.each { |f| File.delete(f) } if delete_shards
46+
47+
{ shards: shard_files.size, result: result }
48+
end
49+
50+
private
51+
52+
def shard_glob
53+
"#{base_path.chomp('.json')}.node-*.json"
54+
end
55+
56+
def parse_shard(file)
57+
JSON.parse(File.read(file))
58+
rescue Errno::ENOENT
59+
raise "Shard file not found: #{file}"
60+
rescue JSON::ParserError => e
61+
raise "Invalid JSON in shard file #{file}: #{e.message}"
62+
end
63+
end
64+
end

spec/shard_merger_spec.rb

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
require "json"
6+
require "tempfile"
7+
require_relative "../lib/deprecation_tracker/shard_merger"
8+
9+
RSpec.describe DeprecationTracker::ShardMerger do
10+
let(:base_path) do
11+
dir = Dir.tmpdir
12+
File.join(dir, "shitlist-#{Process.pid}-#{rand(1000)}.json")
13+
end
14+
15+
after do
16+
FileUtils.rm_f(base_path)
17+
Dir.glob("#{base_path.chomp('.json')}.node-*.json").each { |f| FileUtils.rm_f(f) }
18+
end
19+
20+
def write_shard(index, data)
21+
path = "#{base_path.chomp('.json')}.node-#{index}.json"
22+
File.write(path, JSON.pretty_generate(data))
23+
path
24+
end
25+
26+
subject { described_class.new(base_path) }
27+
28+
it "merges multiple shard files into the canonical file" do
29+
write_shard(0, { "bucket 1" => ["a"], "bucket 2" => ["b"] })
30+
write_shard(1, { "bucket 3" => ["c"] })
31+
32+
output = subject.merge
33+
result = output[:result]
34+
35+
expect(result).to eq(
36+
"bucket 1" => ["a"],
37+
"bucket 2" => ["b"],
38+
"bucket 3" => ["c"]
39+
)
40+
expect(output[:shards]).to eq(2)
41+
expect(JSON.parse(File.read(base_path))).to eq(result)
42+
end
43+
44+
it "deep-merges overlapping buckets by concatenating and sorting messages" do
45+
write_shard(0, { "bucket 1" => ["b", "a"] })
46+
write_shard(1, { "bucket 1" => ["c", "a"] })
47+
48+
result = subject.merge[:result]
49+
50+
expect(result).to eq("bucket 1" => ["a", "a", "b", "c"])
51+
end
52+
53+
it "warns and returns empty result when no shards exist" do
54+
expect { output = subject.merge }.to output(/No shards found/).to_stderr
55+
56+
output = subject.merge
57+
58+
expect(output[:result]).to eq({})
59+
expect(output[:shards]).to eq(0)
60+
expect(File.exist?(base_path)).to be false
61+
end
62+
63+
it "warns and returns empty result when directory does not exist" do
64+
merger = described_class.new("/nonexistent/path/shitlist.json")
65+
66+
expect { output = merger.merge }.to output(/Directory does not exist/).to_stderr
67+
68+
output = merger.merge
69+
70+
expect(output[:result]).to eq({})
71+
expect(output[:shards]).to eq(0)
72+
end
73+
74+
it "handles a single shard file" do
75+
write_shard(0, { "bucket 1" => ["a"] })
76+
77+
output = subject.merge
78+
79+
expect(output[:result]).to eq("bucket 1" => ["a"])
80+
expect(output[:shards]).to eq(1)
81+
end
82+
83+
it "deletes shard files when delete_shards is true" do
84+
shard0 = write_shard(0, { "bucket 1" => ["a"] })
85+
shard1 = write_shard(1, { "bucket 2" => ["b"] })
86+
87+
merger = described_class.new(base_path, delete_shards: true)
88+
merger.merge
89+
90+
expect(File.exist?(shard0)).to be false
91+
expect(File.exist?(shard1)).to be false
92+
expect(File.exist?(base_path)).to be true
93+
end
94+
95+
it "preserves shard files by default" do
96+
shard0 = write_shard(0, { "bucket 1" => ["a"] })
97+
98+
subject.merge
99+
100+
expect(File.exist?(shard0)).to be true
101+
end
102+
103+
it "sorts buckets alphabetically" do
104+
write_shard(0, { "z_bucket" => ["a"] })
105+
write_shard(1, { "a_bucket" => ["b"] })
106+
107+
result = subject.merge[:result]
108+
109+
expect(result.keys).to eq(["a_bucket", "z_bucket"])
110+
end
111+
112+
it "raises an error for invalid JSON in a shard file" do
113+
shard_path = "#{base_path.chomp('.json')}.node-0.json"
114+
File.write(shard_path, "not valid json")
115+
116+
expect { subject.merge }.to raise_error(/Invalid JSON in shard file/)
117+
end
118+
end

0 commit comments

Comments
 (0)