trakable 0.2.0
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 +7 -0
- data/.rubocop.yml +81 -0
- data/CHANGELOG.md +50 -0
- data/LICENSE +21 -0
- data/README.md +330 -0
- data/Rakefile +16 -0
- data/benchmark/full_benchmark.rb +221 -0
- data/benchmark/integration_memory.rb +70 -0
- data/benchmark/memory_benchmark.rb +141 -0
- data/benchmark/perf_benchmark.rb +130 -0
- data/integration/README.md +65 -0
- data/integration/run_all.rb +62 -0
- data/integration/scenarios/01-basic-tracking/scenario.rb +51 -0
- data/integration/scenarios/02-revert-restoration/scenario.rb +103 -0
- data/integration/scenarios/03-whodunnit-tracking/scenario.rb +72 -0
- data/integration/scenarios/04-cleanup-retention/scenario.rb +66 -0
- data/integration/scenarios/05-without-tracking/scenario.rb +62 -0
- data/integration/scenarios/06-callback-lifecycle/scenario.rb +103 -0
- data/integration/scenarios/07-global-config/scenario.rb +52 -0
- data/integration/scenarios/08-controller-integration/scenario.rb +44 -0
- data/integration/scenarios/09-cleanup-max-traks/scenario.rb +58 -0
- data/integration/scenarios/10-model-configuration/scenario.rb +68 -0
- data/integration/scenarios/11-conditional-tracking/scenario.rb +48 -0
- data/integration/scenarios/12-metadata/scenario.rb +54 -0
- data/integration/scenarios/13-traks-association/scenario.rb +80 -0
- data/integration/scenarios/14-time-travel/scenario.rb +132 -0
- data/integration/scenarios/15-diffing-changeset/scenario.rb +109 -0
- data/integration/scenarios/16-serialization/scenario.rb +159 -0
- data/integration/scenarios/17-associations-tracking/scenario.rb +143 -0
- data/integration/scenarios/18-bulk-operations/scenario.rb +70 -0
- data/integration/scenarios/19-transactions/scenario.rb +89 -0
- data/integration/scenarios/20-performance/scenario.rb +89 -0
- data/integration/scenarios/21-storage-backends/scenario.rb +52 -0
- data/integration/scenarios/22-multi-tenancy/scenario.rb +49 -0
- data/integration/scenarios/23-sti/scenario.rb +58 -0
- data/integration/scenarios/24-edge-cases-part1/scenario.rb +86 -0
- data/integration/scenarios/25-edge-cases-part2/scenario.rb +74 -0
- data/integration/scenarios/26-edge-cases-part3/scenario.rb +76 -0
- data/integration/scenarios/27-api-query-interface/scenario.rb +78 -0
- data/integration/scenarios/28-security-compliance/scenario.rb +61 -0
- data/integration/scenarios/29-soft-delete/scenario.rb +43 -0
- data/integration/scenarios/30-custom-events/scenario.rb +45 -0
- data/integration/scenarios/31-gem-packaging/scenario.rb +58 -0
- data/integration/scenarios/32-bypass-fail-closed/scenario.rb +77 -0
- data/integration/scenarios/33-coexistence-standalone/scenario.rb +53 -0
- data/integration/scenarios/34-real-tracking/scenario.rb +254 -0
- data/integration/scenarios/35-revert-undo/scenario.rb +235 -0
- data/integration/scenarios/36-whodunnit-deep/scenario.rb +281 -0
- data/integration/scenarios/37-real-world-use-cases/scenario.rb +1213 -0
- data/integration/scenarios/38-concurrency/scenario.rb +163 -0
- data/integration/scenarios/39-query-scopes/scenario.rb +126 -0
- data/integration/scenarios/40-whodunnit-config/scenario.rb +113 -0
- data/integration/scenarios/41-batch-cleanup/scenario.rb +186 -0
- data/integration/scenarios/scenario_runner.rb +68 -0
- data/lib/generators/trakable/install_generator.rb +28 -0
- data/lib/generators/trakable/templates/create_traks_migration.rb +23 -0
- data/lib/generators/trakable/templates/trakable_initializer.rb +15 -0
- data/lib/trakable/cleanup.rb +89 -0
- data/lib/trakable/config.rb +22 -0
- data/lib/trakable/context.rb +85 -0
- data/lib/trakable/controller.rb +25 -0
- data/lib/trakable/model.rb +99 -0
- data/lib/trakable/railtie.rb +28 -0
- data/lib/trakable/revertable.rb +166 -0
- data/lib/trakable/tracker.rb +134 -0
- data/lib/trakable/trak.rb +98 -0
- data/lib/trakable/version.rb +5 -0
- data/lib/trakable.rb +51 -0
- data/trakable.gemspec +41 -0
- metadata +242 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Unified Benchmark for Trakable
|
|
5
|
+
# Measures 5 dimensions in one run: Boot, Speed, Memory, Storage, Integration
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# ruby benchmark/full_benchmark.rb
|
|
9
|
+
# ruby benchmark/full_benchmark.rb > /tmp/before.txt
|
|
10
|
+
# # ... apply changes ...
|
|
11
|
+
# ruby benchmark/full_benchmark.rb > /tmp/after.txt
|
|
12
|
+
# diff /tmp/before.txt /tmp/after.txt
|
|
13
|
+
|
|
14
|
+
ENV['DISABLE_COVERAGE'] = '1'
|
|
15
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
|
16
|
+
|
|
17
|
+
require 'benchmark'
|
|
18
|
+
require 'json'
|
|
19
|
+
require 'trakable'
|
|
20
|
+
|
|
21
|
+
SPEED_ITERATIONS = 50_000
|
|
22
|
+
SPEED_RUNS = 5
|
|
23
|
+
ALLOC_ITERATIONS = 10_000
|
|
24
|
+
|
|
25
|
+
results = {}
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# A. Boot — subprocess `ruby -e "require 'trakable'"` × 5, median
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
boot_times = 5.times.map do
|
|
31
|
+
cmd = %(ruby -I#{File.expand_path('../lib', __dir__)} -e "require 'trakable'")
|
|
32
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
33
|
+
system(cmd, out: File::NULL, err: File::NULL)
|
|
34
|
+
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
35
|
+
((t1 - t0) * 1_000_000).round(0)
|
|
36
|
+
end.sort
|
|
37
|
+
results['boot_time_us'] = boot_times[2] # median
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Mocks
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
class BenchRecord
|
|
43
|
+
attr_accessor :trakable_options, :previous_changes
|
|
44
|
+
|
|
45
|
+
def initialize
|
|
46
|
+
@trakable_options = { only: %w[title body], ignore: %w[status] }
|
|
47
|
+
@previous_changes = {
|
|
48
|
+
'title' => %w[OldTitle NewTitle],
|
|
49
|
+
'body' => %w[OldBody NewBody],
|
|
50
|
+
'views' => [0, 10],
|
|
51
|
+
'updated_at' => [Time.now - 3600, Time.now],
|
|
52
|
+
'status' => %w[draft published]
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def id = 1
|
|
57
|
+
def self.name = 'BenchRecord'
|
|
58
|
+
|
|
59
|
+
def attributes
|
|
60
|
+
{ 'id' => 1, 'title' => 'NewTitle', 'body' => 'NewBody',
|
|
61
|
+
'views' => 10, 'status' => 'published' }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class WideRecord
|
|
66
|
+
attr_accessor :trakable_options, :previous_changes
|
|
67
|
+
|
|
68
|
+
def initialize
|
|
69
|
+
@trakable_options = {}
|
|
70
|
+
@previous_changes = {
|
|
71
|
+
'field_03' => %w[old new],
|
|
72
|
+
'field_07' => %w[old new]
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def id = 2
|
|
77
|
+
def self.name = 'WideRecord'
|
|
78
|
+
|
|
79
|
+
def attributes
|
|
80
|
+
h = { 'id' => 2 }
|
|
81
|
+
20.times { |i| h["field_#{format('%02d', i)}"] = "value_#{i}" }
|
|
82
|
+
h
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# B. Speed — Benchmark.realtime × SPEED_ITERATIONS × SPEED_RUNS, median
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
def median_speed(record, event)
|
|
90
|
+
# warmup
|
|
91
|
+
100.times { Trakable::Tracker.call(record, event) }
|
|
92
|
+
|
|
93
|
+
runs = SPEED_RUNS.times.map do
|
|
94
|
+
t = Benchmark.realtime { SPEED_ITERATIONS.times { Trakable::Tracker.call(record, event) } }
|
|
95
|
+
(t / SPEED_ITERATIONS * 1_000_000).round(2)
|
|
96
|
+
end.sort
|
|
97
|
+
runs[2]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
bench = BenchRecord.new
|
|
101
|
+
results['speed_create_us'] = median_speed(bench, 'create')
|
|
102
|
+
results['speed_update_us'] = median_speed(bench, 'update')
|
|
103
|
+
results['speed_destroy_us'] = median_speed(bench, 'destroy')
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# C. Memory — GC.stat[:total_allocated_objects] × ALLOC_ITERATIONS
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
def measure_allocs(record, event)
|
|
109
|
+
# warmup
|
|
110
|
+
100.times { Trakable::Tracker.call(record, event) }
|
|
111
|
+
|
|
112
|
+
GC.start
|
|
113
|
+
GC.disable
|
|
114
|
+
before = GC.stat[:total_allocated_objects]
|
|
115
|
+
ALLOC_ITERATIONS.times { Trakable::Tracker.call(record, event) }
|
|
116
|
+
after = GC.stat[:total_allocated_objects]
|
|
117
|
+
GC.enable
|
|
118
|
+
((after - before).to_f / ALLOC_ITERATIONS).round(1)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def measure_allocs_breakdown(record, event)
|
|
122
|
+
100.times { Trakable::Tracker.call(record, event) }
|
|
123
|
+
|
|
124
|
+
GC.start
|
|
125
|
+
GC.disable
|
|
126
|
+
before = ObjectSpace.count_objects.dup
|
|
127
|
+
ALLOC_ITERATIONS.times { Trakable::Tracker.call(record, event) }
|
|
128
|
+
after = ObjectSpace.count_objects
|
|
129
|
+
GC.enable
|
|
130
|
+
|
|
131
|
+
result = {}
|
|
132
|
+
after.each do |type, count|
|
|
133
|
+
diff = count - (before[type] || 0)
|
|
134
|
+
result[type] = (diff.to_f / ALLOC_ITERATIONS).round(1) if diff > 0
|
|
135
|
+
end
|
|
136
|
+
result
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
bench = BenchRecord.new
|
|
140
|
+
results['allocs_create'] = measure_allocs(bench, 'create')
|
|
141
|
+
results['allocs_update'] = measure_allocs(bench, 'update')
|
|
142
|
+
results['allocs_destroy'] = measure_allocs(bench, 'destroy')
|
|
143
|
+
|
|
144
|
+
breakdown = measure_allocs_breakdown(bench, 'update')
|
|
145
|
+
breakdown.each do |type, val|
|
|
146
|
+
results["allocs_update_#{type}"] = val
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# D. Storage — JSON.generate(trak.object).bytesize
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
def storage_metrics(record, event, prefix)
|
|
153
|
+
trak = Trakable::Tracker.call(record, event)
|
|
154
|
+
result = {}
|
|
155
|
+
obj = trak.object
|
|
156
|
+
cs = trak.changeset
|
|
157
|
+
|
|
158
|
+
result["#{prefix}object_bytes"] = obj ? JSON.generate(obj).bytesize : 0
|
|
159
|
+
result["#{prefix}changeset_bytes"] = cs && !cs.empty? ? JSON.generate(cs).bytesize : 0
|
|
160
|
+
result["#{prefix}total_bytes"] = result["#{prefix}object_bytes"] + result["#{prefix}changeset_bytes"]
|
|
161
|
+
result
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
bench = BenchRecord.new
|
|
165
|
+
results.merge!(storage_metrics(bench, 'update', 'storage_'))
|
|
166
|
+
results.merge!(storage_metrics(bench, 'destroy', 'storage_destroy_'))
|
|
167
|
+
|
|
168
|
+
wide = WideRecord.new
|
|
169
|
+
results.merge!(storage_metrics(wide, 'update', 'storage_wide_'))
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# E. Integration — Load scenarios with GC counting
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
scenarios_dir = File.expand_path('../integration/scenarios', __dir__)
|
|
175
|
+
if Dir.exist?(scenarios_dir)
|
|
176
|
+
scenario_dirs = Dir.glob("#{scenarios_dir}/*").select { |d| File.directory?(d) }.sort
|
|
177
|
+
total_allocs = 0
|
|
178
|
+
scenario_count = 0
|
|
179
|
+
|
|
180
|
+
real_stdout = $stdout
|
|
181
|
+
|
|
182
|
+
scenario_dirs.each do |dir|
|
|
183
|
+
scenario_file = File.join(dir, 'scenario.rb')
|
|
184
|
+
next unless File.exist?(scenario_file)
|
|
185
|
+
|
|
186
|
+
Trakable::Context.reset!
|
|
187
|
+
$stdout = File.open(File::NULL, 'w')
|
|
188
|
+
|
|
189
|
+
GC.start
|
|
190
|
+
GC.disable
|
|
191
|
+
before = GC.stat[:total_allocated_objects]
|
|
192
|
+
|
|
193
|
+
begin
|
|
194
|
+
load scenario_file
|
|
195
|
+
rescue StandardError
|
|
196
|
+
# skip failures
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
after = GC.stat[:total_allocated_objects]
|
|
200
|
+
GC.enable
|
|
201
|
+
|
|
202
|
+
$stdout.close
|
|
203
|
+
$stdout = real_stdout
|
|
204
|
+
|
|
205
|
+
total_allocs += (after - before)
|
|
206
|
+
scenario_count += 1
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
$stdout = real_stdout
|
|
210
|
+
results['integration_scenarios'] = scenario_count
|
|
211
|
+
results['integration_total_allocs'] = total_allocs
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
# Output — sorted table, diffable
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
puts format('%-40s | %s', 'metric', 'value')
|
|
218
|
+
puts '-' * 60
|
|
219
|
+
results.sort_by { |k, _| k }.each do |key, value|
|
|
220
|
+
puts format('%-40s | %s', key, value)
|
|
221
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Integration Memory Benchmark for Trakable
|
|
5
|
+
# Measures allocations across all integration scenarios
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# ruby benchmark/integration_memory.rb
|
|
9
|
+
|
|
10
|
+
ENV['DISABLE_COVERAGE'] = '1'
|
|
11
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
|
12
|
+
require 'trakable'
|
|
13
|
+
|
|
14
|
+
scenarios_dir = File.expand_path('../integration/scenarios', __dir__)
|
|
15
|
+
scenario_dirs = Dir.glob("#{scenarios_dir}/*").select { |d| File.directory?(d) }.sort
|
|
16
|
+
|
|
17
|
+
results = []
|
|
18
|
+
real_stdout = $stdout
|
|
19
|
+
|
|
20
|
+
scenario_dirs.each do |dir|
|
|
21
|
+
scenario_file = File.join(dir, 'scenario.rb')
|
|
22
|
+
next unless File.exist?(scenario_file)
|
|
23
|
+
|
|
24
|
+
name = File.basename(dir)
|
|
25
|
+
Trakable::Context.reset!
|
|
26
|
+
|
|
27
|
+
# Suppress output
|
|
28
|
+
$stdout = File.open(File::NULL, 'w')
|
|
29
|
+
|
|
30
|
+
GC.start
|
|
31
|
+
GC.disable
|
|
32
|
+
before = GC.stat[:total_allocated_objects]
|
|
33
|
+
|
|
34
|
+
begin
|
|
35
|
+
load scenario_file
|
|
36
|
+
status = 'PASS'
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
status = "FAIL"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
after = GC.stat[:total_allocated_objects]
|
|
42
|
+
GC.enable
|
|
43
|
+
|
|
44
|
+
$stdout.close
|
|
45
|
+
$stdout = real_stdout
|
|
46
|
+
|
|
47
|
+
results << { name: name, allocs: after - before, status: status }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
$stdout = real_stdout
|
|
51
|
+
|
|
52
|
+
puts '=' * 70
|
|
53
|
+
puts 'Trakable Integration Memory Benchmark'
|
|
54
|
+
puts '=' * 70
|
|
55
|
+
puts
|
|
56
|
+
|
|
57
|
+
total = 0
|
|
58
|
+
results.each do |r|
|
|
59
|
+
total += r[:allocs]
|
|
60
|
+
flag = r[:status] == 'PASS' ? ' ' : 'F'
|
|
61
|
+
printf "[%s] %-45s %7d allocs\n", flag, r[:name][0..44], r[:allocs]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
puts '-' * 70
|
|
65
|
+
printf " %-45s %7d allocs\n", "TOTAL (#{results.size} scenarios)", total
|
|
66
|
+
puts
|
|
67
|
+
|
|
68
|
+
gc = GC.stat
|
|
69
|
+
puts "GC runs: #{gc[:count]} | Live objects: #{gc[:heap_live_slots]}"
|
|
70
|
+
puts '=' * 70
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Memory & Allocation Benchmark for Trakable
|
|
5
|
+
# Measures object allocations and GC pressure for common operations
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# ruby benchmark/memory_benchmark.rb
|
|
9
|
+
|
|
10
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
|
11
|
+
require 'trakable'
|
|
12
|
+
|
|
13
|
+
puts '=' * 60
|
|
14
|
+
puts 'Trakable Memory & Allocation Benchmark'
|
|
15
|
+
puts '=' * 60
|
|
16
|
+
puts
|
|
17
|
+
|
|
18
|
+
ITERATIONS = 10_000
|
|
19
|
+
|
|
20
|
+
# --- Helpers ---
|
|
21
|
+
|
|
22
|
+
def measure_allocs(n = ITERATIONS)
|
|
23
|
+
GC.start
|
|
24
|
+
GC.disable
|
|
25
|
+
before = GC.stat[:total_allocated_objects]
|
|
26
|
+
n.times { yield }
|
|
27
|
+
after = GC.stat[:total_allocated_objects]
|
|
28
|
+
GC.enable
|
|
29
|
+
(after - before).to_f / n
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def measure_allocs_by_type(n = ITERATIONS)
|
|
33
|
+
GC.start
|
|
34
|
+
GC.disable
|
|
35
|
+
before = ObjectSpace.count_objects.dup
|
|
36
|
+
n.times { yield }
|
|
37
|
+
after = ObjectSpace.count_objects
|
|
38
|
+
GC.enable
|
|
39
|
+
result = {}
|
|
40
|
+
after.each do |type, count|
|
|
41
|
+
diff = count - (before[type] || 0)
|
|
42
|
+
result[type] = (diff.to_f / n).round(1) if diff > 0
|
|
43
|
+
end
|
|
44
|
+
result
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def measure_memory_bytes(n = ITERATIONS)
|
|
48
|
+
GC.start
|
|
49
|
+
before = GC.stat[:malloc_increase_bytes]
|
|
50
|
+
n.times { yield }
|
|
51
|
+
after = GC.stat[:malloc_increase_bytes]
|
|
52
|
+
((after - before).to_f / n).round(1)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# --- Mock ---
|
|
56
|
+
|
|
57
|
+
class MockRecord
|
|
58
|
+
attr_accessor :trakable_options, :previous_changes
|
|
59
|
+
|
|
60
|
+
def initialize
|
|
61
|
+
@trakable_options = { only: %w[title body], ignore: %w[status] }
|
|
62
|
+
@previous_changes = {
|
|
63
|
+
'title' => %w[Old New], 'body' => %w[Old New],
|
|
64
|
+
'views' => [0, 10], 'updated_at' => [Time.now - 3600, Time.now],
|
|
65
|
+
'status' => %w[draft published]
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def id
|
|
70
|
+
1
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def attributes
|
|
74
|
+
{ 'id' => 1, 'title' => 'New', 'body' => 'New', 'views' => 10, 'status' => 'published' }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# --- Tests ---
|
|
79
|
+
|
|
80
|
+
record = MockRecord.new
|
|
81
|
+
|
|
82
|
+
# Warmup
|
|
83
|
+
5.times { Trakable::Tracker.call(record, 'update') }
|
|
84
|
+
|
|
85
|
+
puts "1. Full Tracker.call (update with only/ignore)"
|
|
86
|
+
puts '-' * 50
|
|
87
|
+
allocs = measure_allocs { Trakable::Tracker.call(record, 'update') }
|
|
88
|
+
types = measure_allocs_by_type { Trakable::Tracker.call(record, 'update') }
|
|
89
|
+
puts " Allocations per call: #{allocs.round(1)}"
|
|
90
|
+
puts " Breakdown: #{types.select { |_, v| v > 0 }.map { |k, v| "#{k}=#{v}" }.join(', ')}"
|
|
91
|
+
puts
|
|
92
|
+
|
|
93
|
+
puts "2. Tracker.call (create, no changeset)"
|
|
94
|
+
puts '-' * 50
|
|
95
|
+
allocs = measure_allocs { Trakable::Tracker.call(record, 'create') }
|
|
96
|
+
types = measure_allocs_by_type { Trakable::Tracker.call(record, 'create') }
|
|
97
|
+
puts " Allocations per call: #{allocs.round(1)}"
|
|
98
|
+
puts " Breakdown: #{types.select { |_, v| v > 0 }.map { |k, v| "#{k}=#{v}" }.join(', ')}"
|
|
99
|
+
puts
|
|
100
|
+
|
|
101
|
+
puts "3. Tracker.call (destroy, full object state)"
|
|
102
|
+
puts '-' * 50
|
|
103
|
+
allocs = measure_allocs { Trakable::Tracker.call(record, 'destroy') }
|
|
104
|
+
types = measure_allocs_by_type { Trakable::Tracker.call(record, 'destroy') }
|
|
105
|
+
puts " Allocations per call: #{allocs.round(1)}"
|
|
106
|
+
puts " Breakdown: #{types.select { |_, v| v > 0 }.map { |k, v| "#{k}=#{v}" }.join(', ')}"
|
|
107
|
+
puts
|
|
108
|
+
|
|
109
|
+
puts "4. Trak.build only"
|
|
110
|
+
puts '-' * 50
|
|
111
|
+
allocs = measure_allocs { Trakable::Trak.build(item: record, event: 'update', changeset: {}, object: {}) }
|
|
112
|
+
types = measure_allocs_by_type { Trakable::Trak.build(item: record, event: 'update', changeset: {}, object: {}) }
|
|
113
|
+
puts " Allocations per call: #{allocs.round(1)}"
|
|
114
|
+
puts " Breakdown: #{types.select { |_, v| v > 0 }.map { |k, v| "#{k}=#{v}" }.join(', ')}"
|
|
115
|
+
puts
|
|
116
|
+
|
|
117
|
+
puts "5. filter_changeset only"
|
|
118
|
+
puts '-' * 50
|
|
119
|
+
tracker = Trakable::Tracker.new(record, 'update')
|
|
120
|
+
allocs = measure_allocs { tracker.send(:filter_changeset, record.previous_changes) }
|
|
121
|
+
puts " Allocations per call: #{allocs.round(1)}"
|
|
122
|
+
puts
|
|
123
|
+
|
|
124
|
+
puts "6. build_object_from_previous only"
|
|
125
|
+
puts '-' * 50
|
|
126
|
+
allocs = measure_allocs { tracker.send(:build_object_from_previous) }
|
|
127
|
+
puts " Allocations per call: #{allocs.round(1)}"
|
|
128
|
+
puts
|
|
129
|
+
|
|
130
|
+
# No-filter scenario
|
|
131
|
+
record_no_filter = MockRecord.new
|
|
132
|
+
record_no_filter.trakable_options = {}
|
|
133
|
+
puts "7. Tracker.call (update, no model filters)"
|
|
134
|
+
puts '-' * 50
|
|
135
|
+
allocs = measure_allocs { Trakable::Tracker.call(record_no_filter, 'update') }
|
|
136
|
+
puts " Allocations per call: #{allocs.round(1)}"
|
|
137
|
+
puts
|
|
138
|
+
|
|
139
|
+
puts '=' * 60
|
|
140
|
+
puts 'Memory Benchmark Complete'
|
|
141
|
+
puts '=' * 60
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Performance Benchmark for Trakable
|
|
5
|
+
# Measures tracking overhead for common operations
|
|
6
|
+
|
|
7
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
|
8
|
+
require 'trakable'
|
|
9
|
+
require 'benchmark'
|
|
10
|
+
|
|
11
|
+
puts "=" * 60
|
|
12
|
+
puts "Trakable Performance Benchmark"
|
|
13
|
+
puts "=" * 60
|
|
14
|
+
puts
|
|
15
|
+
|
|
16
|
+
# Configuration
|
|
17
|
+
ITERATIONS = 50_000
|
|
18
|
+
|
|
19
|
+
# Test 1: Filter changeset with only + ignore
|
|
20
|
+
puts "Test 1: filter_changeset with only/ignore (#{ITERATIONS} iterations)"
|
|
21
|
+
puts "-" * 50
|
|
22
|
+
|
|
23
|
+
# Simulate a record with trakable_options
|
|
24
|
+
class MockRecord
|
|
25
|
+
attr_accessor :trakable_options
|
|
26
|
+
|
|
27
|
+
def previous_changes
|
|
28
|
+
@previous_changes ||= {
|
|
29
|
+
'title' => ['Old Title', 'New Title'],
|
|
30
|
+
'body' => ['Old Body', 'New Body'],
|
|
31
|
+
'views' => [0, 10],
|
|
32
|
+
'updated_at' => [Time.now - 3600, Time.now],
|
|
33
|
+
'status' => ['draft', 'published']
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
record = MockRecord.new
|
|
39
|
+
record.trakable_options = { only: %i[title body], ignore: %i[status] }
|
|
40
|
+
|
|
41
|
+
tracker = Trakable::Tracker.new(record, 'update')
|
|
42
|
+
|
|
43
|
+
time = Benchmark.realtime do
|
|
44
|
+
ITERATIONS.times { tracker.send(:filter_changeset, record.previous_changes.dup) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
puts "Time: #{(time * 1000).round(2)}ms"
|
|
48
|
+
puts "Per iteration: #{(time / ITERATIONS * 1_000_000).round(2)}µs"
|
|
49
|
+
baseline_filter = time
|
|
50
|
+
puts
|
|
51
|
+
|
|
52
|
+
# Test 2: Filter changeset without only/ignore (fast path)
|
|
53
|
+
puts "Test 2: filter_changeset without filters (#{ITERATIONS} iterations)"
|
|
54
|
+
puts "-" * 50
|
|
55
|
+
|
|
56
|
+
record2 = MockRecord.new
|
|
57
|
+
record2.trakable_options = {}
|
|
58
|
+
tracker2 = Trakable::Tracker.new(record2, 'update')
|
|
59
|
+
|
|
60
|
+
time = Benchmark.realtime do
|
|
61
|
+
ITERATIONS.times { tracker2.send(:filter_changeset, record2.previous_changes.dup) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
puts "Time: #{(time * 1000).round(2)}ms"
|
|
65
|
+
puts "Per iteration: #{(time / ITERATIONS * 1_000_000).round(2)}µs"
|
|
66
|
+
puts
|
|
67
|
+
|
|
68
|
+
# Test 3: String conversion overhead
|
|
69
|
+
puts "Test 3: Array().map(&:to_s) overhead (#{ITERATIONS} iterations)"
|
|
70
|
+
puts "-" * 50
|
|
71
|
+
|
|
72
|
+
only_symbols = %i[title body views status]
|
|
73
|
+
only_strings = %w[title body views status]
|
|
74
|
+
|
|
75
|
+
time_symbols = Benchmark.realtime do
|
|
76
|
+
ITERATIONS.times { Array(only_symbols).map(&:to_s) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
time_strings = Benchmark.realtime do
|
|
80
|
+
ITERATIONS.times { Array(only_strings).map(&:to_s) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
puts "Symbols: #{(time_symbols * 1000).round(2)}ms"
|
|
84
|
+
puts "Pre-converted strings: #{(time_strings * 1000).round(2)}ms"
|
|
85
|
+
puts "Speedup: #{(time_symbols / time_strings).round(2)}x"
|
|
86
|
+
puts
|
|
87
|
+
|
|
88
|
+
# Test 4: Set vs Array for ignore lookups
|
|
89
|
+
puts "Test 4: Set vs Array for ignore lookups (#{ITERATIONS} iterations)"
|
|
90
|
+
puts "-" * 50
|
|
91
|
+
|
|
92
|
+
ignore_array = %w[updated_at created_at id views status]
|
|
93
|
+
ignore_set = ignore_array.to_set
|
|
94
|
+
keys = %w[title body views status updated_at created_at id]
|
|
95
|
+
|
|
96
|
+
time_array = Benchmark.realtime do
|
|
97
|
+
ITERATIONS.times { keys.reject { |k| ignore_array.include?(k) } }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
time_set = Benchmark.realtime do
|
|
101
|
+
ITERATIONS.times { keys.reject { |k| ignore_set.include?(k) } }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
puts "Array#include?: #{(time_array * 1000).round(2)}ms"
|
|
105
|
+
puts "Set#include?: #{(time_set * 1000).round(2)}ms"
|
|
106
|
+
puts "Speedup: #{(time_array / time_set).round(2)}x"
|
|
107
|
+
puts
|
|
108
|
+
|
|
109
|
+
# Test 5: respond_to? vs direct check
|
|
110
|
+
puts "Test 5: respond_to? overhead (#{ITERATIONS} iterations)"
|
|
111
|
+
puts "-" * 50
|
|
112
|
+
|
|
113
|
+
obj = Object.new
|
|
114
|
+
|
|
115
|
+
time_respond = Benchmark.realtime do
|
|
116
|
+
ITERATIONS.times { obj.respond_to?(:trakable_options) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
time_direct = Benchmark.realtime do
|
|
120
|
+
ITERATIONS.times { obj.is_a?(MockRecord) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
puts "respond_to?: #{(time_respond * 1000).round(2)}ms"
|
|
124
|
+
puts "is_a?: #{(time_direct * 1000).round(2)}ms"
|
|
125
|
+
puts
|
|
126
|
+
|
|
127
|
+
puts "=" * 60
|
|
128
|
+
puts "Benchmark Complete"
|
|
129
|
+
puts "Baseline filter_changeset: #{(baseline_filter / ITERATIONS * 1_000_000).round(2)}µs per call"
|
|
130
|
+
puts "=" * 60
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Trakable Integration Tests
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Integration tests simulate real-world usage scenarios of the Trakable gem. Each scenario tests a complete feature or workflow from start to finish.
|
|
6
|
+
|
|
7
|
+
## Running Tests
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Run all integration scenarios
|
|
11
|
+
ruby integration/run_all.rb
|
|
12
|
+
|
|
13
|
+
# Run a specific scenario
|
|
14
|
+
ruby integration/scenarios/01-basic-tracking/scenario.rb
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Scenarios
|
|
18
|
+
|
|
19
|
+
| # | Scenario | Description |
|
|
20
|
+
|---|----------|-------------|
|
|
21
|
+
| 01 | Basic Tracking | Tests create/update/destroy tracking |
|
|
22
|
+
| 02 | Revert & Restoration | Tests revert! and reify functionality |
|
|
23
|
+
| 03 | Whodunnit Tracking | Tests polymorphic whodunnit and context |
|
|
24
|
+
| 04 | Cleanup & Retention | Tests max_traks and retention policies |
|
|
25
|
+
| 05 | Without Tracking | Tests skipping tracking |
|
|
26
|
+
|
|
27
|
+
## Creating a New Scenario
|
|
28
|
+
|
|
29
|
+
1. Create a new directory: `integration/scenarios/NN-description/`
|
|
30
|
+
2. Create `scenario.rb` with your test code
|
|
31
|
+
3. Include the scenario runner:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
# frozen_string_literal: true
|
|
35
|
+
|
|
36
|
+
require_relative '../scenario_runner'
|
|
37
|
+
|
|
38
|
+
run_scenario 'My Scenario Name' do
|
|
39
|
+
puts '=== Scenario NN: My Scenario Name ==='
|
|
40
|
+
|
|
41
|
+
# Your test code here
|
|
42
|
+
|
|
43
|
+
puts '=== Scenario NN PASSED ✓ ==='
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Scenario Structure
|
|
48
|
+
|
|
49
|
+
Each scenario should:
|
|
50
|
+
- Be self-contained and runnable independently
|
|
51
|
+
- Clean up after itself (reset context, etc.)
|
|
52
|
+
- Print clear progress messages
|
|
53
|
+
- Use assertions to verify behavior
|
|
54
|
+
- Handle exceptions gracefully
|
|
55
|
+
|
|
56
|
+
## Available Assertions
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
assert(condition, message)
|
|
60
|
+
assert_equal(expected, actual)
|
|
61
|
+
assert_kind_of(Class, object)
|
|
62
|
+
assert_includes(collection, item)
|
|
63
|
+
refute(condition, message)
|
|
64
|
+
refute_nil(object)
|
|
65
|
+
```
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Trakable Integration Test Runner
|
|
5
|
+
# Runs all scenario tests in order
|
|
6
|
+
|
|
7
|
+
puts '=' * 70
|
|
8
|
+
puts 'TRAKABLE INTEGRATION TESTS'
|
|
9
|
+
puts '=' * 70
|
|
10
|
+
puts
|
|
11
|
+
|
|
12
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
|
13
|
+
require 'trakable'
|
|
14
|
+
require 'minitest/autorun'
|
|
15
|
+
|
|
16
|
+
# Reset context before tests
|
|
17
|
+
Trakable::Context.reset!
|
|
18
|
+
|
|
19
|
+
# Track results
|
|
20
|
+
results = { passed: 0, failed: 0, errors: [] }
|
|
21
|
+
|
|
22
|
+
# Run each scenario
|
|
23
|
+
scenarios_dir = File.expand_path('scenarios', __dir__)
|
|
24
|
+
scenario_dirs = Dir.glob("#{scenarios_dir}/*").select { |d| File.directory?(d) }.sort
|
|
25
|
+
|
|
26
|
+
scenario_dirs.each do |dir|
|
|
27
|
+
scenario_file = File.join(dir, 'scenario.rb')
|
|
28
|
+
next unless File.exist?(scenario_file)
|
|
29
|
+
|
|
30
|
+
puts "\n#{'=' * 70}"
|
|
31
|
+
puts "Running: #{File.basename(dir)}"
|
|
32
|
+
puts '=' * 70
|
|
33
|
+
|
|
34
|
+
begin
|
|
35
|
+
load scenario_file
|
|
36
|
+
results[:passed] += 1
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
results[:failed] += 1
|
|
39
|
+
results[:errors] << { scenario: File.basename(dir), error: e.message }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Reset context between scenarios
|
|
43
|
+
Trakable::Context.reset!
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Summary
|
|
47
|
+
puts "\n#{'=' * 70}"
|
|
48
|
+
puts 'INTEGRATION TEST SUMMARY'
|
|
49
|
+
puts '=' * 70
|
|
50
|
+
puts "Passed: #{results[:passed]}"
|
|
51
|
+
puts "Failed: #{results[:failed]}"
|
|
52
|
+
|
|
53
|
+
if results[:errors].any?
|
|
54
|
+
puts "\nFailures:"
|
|
55
|
+
results[:errors].each do |error|
|
|
56
|
+
puts " - #{error[:scenario]}: #{error[:error]}"
|
|
57
|
+
end
|
|
58
|
+
exit 1
|
|
59
|
+
else
|
|
60
|
+
puts "\nAll integration tests passed! ✓"
|
|
61
|
+
exit 0
|
|
62
|
+
end
|