From 26a312f9b0e7334399e014ca3676f46119946151 Mon Sep 17 00:00:00 2001 From: Connor Shea <2977353+connorshea@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:05:11 -0600 Subject: [PATCH 1/3] perf: accumulate TimeTracker paths in place instead of rebuilding the hash `example_group_finished` merged the finished test file's paths into the run-wide accumulator with `Hash#merge`, which builds a whole new hash. It runs once per top level group, so the work is quadratic in the number of test files a CI node executes. Accumulate into the existing hash instead. The entries are private to the formatter and their `:path` already equals the key they are stored under, so mutating the recorded time in place is equivalent. Driving the formatter with 2000 test files of 4 examples each: 21 ms and 74 MB of transient hash-table churn -> 15 ms and 2.0 MB. The merge is the whole quadratic term; isolated, it costs 0.4/1.7/5.8/20.2 ms at 500/1000/2000/4000 files, roughly 4x per doubling. With split-by-test-example the accumulator is keyed per example rather than per file, so it is worse there: 1000 files x 20 examples goes from 74 ms and 404 MB to 46 ms and 7.6 MB. Object allocation counts barely move (`merge` allocates one hash per call); the win is bytes and time, not object count. `merge` is still used by `#queue`, which is called once. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 ++ lib/knapsack_pro/formatters/time_tracker.rb | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59728345..b545280e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ### Unreleased +* RSpec: Speed up the time tracker formatter for test suites with many test files by accumulating recorded paths in place instead of rebuilding the hash after each test file. + ### 10.0.1 * Add support for [File Paths Encryption](https://docs.knapsackpro.com/ruby/encryption/) to [Retry only Failures](https://docs.knapsackpro.com/ruby/retry-only-failures/). diff --git a/lib/knapsack_pro/formatters/time_tracker.rb b/lib/knapsack_pro/formatters/time_tracker.rb index 01baabd6..be06a074 100644 --- a/lib/knapsack_pro/formatters/time_tracker.rb +++ b/lib/knapsack_pro/formatters/time_tracker.rb @@ -81,7 +81,7 @@ def example_group_finished(notification) add_hooks_time(@group, @time_all_by_group_id_path) @time_all_by_group_id_path = Hash.new(0) - @paths = merge(@paths, @group) + merge_into(@paths, @group) @group = {} end @@ -198,6 +198,19 @@ def merge(h1, h2) end end + # `merge` rebuilds the whole accumulator, which is quadratic when called + # once per top level group, so accumulate in place instead. + def merge_into(accumulator, other) + other.each do |path, example| + recorded = accumulator[path] + if recorded + recorded[:time_execution] += example[:time_execution] + else + accumulator[path] = example + end + end + end + def now KnapsackPro::Utils.time_now end From be0d878babebb64e48ec3593e6e9b62841a33f05 Mon Sep 17 00:00:00 2001 From: Connor Shea <2977353+connorshea@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:15:52 -0600 Subject: [PATCH 2/3] refactor: use Hash#key? in TimeTracker#merge_into for consistency `record_example` right above already branches on `accumulator.key?(path)`. The truthiness check was equivalent here, but only because `@paths` is a plain hash whose values are always hashes: on a `Hash.new(0)` accumulator (as `@time_all_by_group_id_path` is) a missing key returns `0`, which is truthy, and `0[:time_execution] +=` would raise. No measurable cost. On a collision-heavy workload (500 files x 20 top level groups, so 9.5k of 10k merges take the collision path) the extra lookup is lost in the noise: 81.0/81.1/81.5 ms before, 79.7/80.1/81.5 ms after, with identical allocations and identical recorded times. Co-Authored-By: Claude Opus 5 (1M context) --- lib/knapsack_pro/formatters/time_tracker.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/knapsack_pro/formatters/time_tracker.rb b/lib/knapsack_pro/formatters/time_tracker.rb index be06a074..d277ddab 100644 --- a/lib/knapsack_pro/formatters/time_tracker.rb +++ b/lib/knapsack_pro/formatters/time_tracker.rb @@ -202,9 +202,8 @@ def merge(h1, h2) # once per top level group, so accumulate in place instead. def merge_into(accumulator, other) other.each do |path, example| - recorded = accumulator[path] - if recorded - recorded[:time_execution] += example[:time_execution] + if accumulator.key?(path) + accumulator[path][:time_execution] += example[:time_execution] else accumulator[path] = example end From 08c629985dac1ca2a1ee7efa9f0c67201b4e9ba4 Mon Sep 17 00:00:00 2001 From: Connor Shea <2977353+connorshea@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:31:13 -0600 Subject: [PATCH 3/3] docs: add patch bump marker and tighten CHANGELOG entry The entry was missing the `(patch)` prefix the repo uses to mark the needed version bump on unreleased entries. Also correct the wording: the merge runs once per top-level example group, not once per test file, so a spec file with several top-level `describe` blocks rebuilt the collection several times. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b545280e..ed25cfaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### Unreleased -* RSpec: Speed up the time tracker formatter for test suites with many test files by accumulating recorded paths in place instead of rebuilding the hash after each test file. +* (patch) RSpec: Speed up the time tracker formatter by recording test path timings in place instead of rebuilding the whole collection after each top-level example group. ### 10.0.1