factory_hoist 0.1.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/LICENSE.txt +21 -0
- data/README.md +125 -0
- data/Rakefile +8 -0
- data/bench/fast_build.rb +37 -0
- data/bench/phase0_synthetic.rb +108 -0
- data/docs/adversarial-audit.md +50 -0
- data/docs/phase0-synthetic.md +19 -0
- data/exe/factory_hoist +15 -0
- data/lib/factory_hoist/advisor.rb +67 -0
- data/lib/factory_hoist/bulk_writer.rb +44 -0
- data/lib/factory_hoist/compatibility.rb +18 -0
- data/lib/factory_hoist/configuration.rb +14 -0
- data/lib/factory_hoist/database_snapshot.rb +54 -0
- data/lib/factory_hoist/deep_copy.rb +11 -0
- data/lib/factory_hoist/definition.rb +27 -0
- data/lib/factory_hoist/fast_build.rb +211 -0
- data/lib/factory_hoist/minitest.rb +78 -0
- data/lib/factory_hoist/parallel_database.rb +87 -0
- data/lib/factory_hoist/pcg32.rb +76 -0
- data/lib/factory_hoist/rspec.rb +37 -0
- data/lib/factory_hoist/runtime.rb +326 -0
- data/lib/factory_hoist/scheduler.rb +133 -0
- data/lib/factory_hoist/stats.rb +52 -0
- data/lib/factory_hoist/transaction.rb +90 -0
- data/lib/factory_hoist/version.rb +5 -0
- data/lib/factory_hoist.rb +151 -0
- data/script/verify_postgresql +102 -0
- data/script/verify_postgresql_clone +61 -0
- metadata +130 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require_relative "database_snapshot"
|
|
5
|
+
require_relative "deep_copy"
|
|
6
|
+
require_relative "definition"
|
|
7
|
+
require_relative "transaction"
|
|
8
|
+
|
|
9
|
+
module FactoryHoist
|
|
10
|
+
module Runtime
|
|
11
|
+
THREAD_KEY = :factory_hoist_runtime
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def current
|
|
16
|
+
Thread.current[THREAD_KEY] ||= Session.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def reset!
|
|
20
|
+
Thread.current[THREAD_KEY]&.close
|
|
21
|
+
ensure
|
|
22
|
+
Thread.current[THREAD_KEY] = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def seed(node_path, key, index = 0)
|
|
26
|
+
input = [FactoryHoist.configuration.suite_seed, node_path, key, index].join("\0")
|
|
27
|
+
OpenSSL::Digest.digest("BLAKE2b512", input).unpack1("Q>")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class Session
|
|
31
|
+
def initialize
|
|
32
|
+
@scopes = []
|
|
33
|
+
@transaction = Transaction.new
|
|
34
|
+
@examples_since_begin = 0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def enter(group, definitions, materialize: true)
|
|
38
|
+
if @scopes.empty?
|
|
39
|
+
@transaction.begin_outer
|
|
40
|
+
@examples_since_begin = 0
|
|
41
|
+
end
|
|
42
|
+
scope = Scope.new(group, definitions, @scopes)
|
|
43
|
+
@transaction.create_savepoint(scope.savepoint)
|
|
44
|
+
@scopes << scope
|
|
45
|
+
scope.materialize! if materialize
|
|
46
|
+
@transaction.clear_written! if materialize && @transaction.owned?
|
|
47
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
48
|
+
@scopes.pop if @scopes.last == scope
|
|
49
|
+
@transaction.rollback_savepoint(scope.savepoint) if scope
|
|
50
|
+
@transaction.rollback_outer if @scopes.empty?
|
|
51
|
+
raise
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def materialize(group)
|
|
55
|
+
scope = @scopes.last
|
|
56
|
+
raise Error, "hoist scope mismatch" unless scope&.group&.equal?(group)
|
|
57
|
+
|
|
58
|
+
preserve_unmanaged_writes
|
|
59
|
+
@transaction.create_savepoint(scope.savepoint)
|
|
60
|
+
scope.materialize!
|
|
61
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
62
|
+
@transaction.rollback_savepoint(scope.savepoint) if scope
|
|
63
|
+
scope&.values&.clear
|
|
64
|
+
raise
|
|
65
|
+
ensure
|
|
66
|
+
@transaction.clear_written! if @transaction.owned?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def leave(group)
|
|
70
|
+
scope = @scopes.last
|
|
71
|
+
return unless scope
|
|
72
|
+
raise Error, "hoist scope mismatch" unless scope.group.equal?(group)
|
|
73
|
+
|
|
74
|
+
@scopes.pop
|
|
75
|
+
@transaction.rollback_savepoint(scope.savepoint)
|
|
76
|
+
@transaction.clear_written! if @transaction.owned?
|
|
77
|
+
if @scopes.empty?
|
|
78
|
+
@transaction.rollback_outer
|
|
79
|
+
@examples_since_begin = 0
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def around_example(example, local: false)
|
|
84
|
+
local_transaction = @scopes.empty? && local
|
|
85
|
+
return example.run if @scopes.empty? && !local
|
|
86
|
+
|
|
87
|
+
@transaction.begin_outer if local_transaction
|
|
88
|
+
|
|
89
|
+
preserve_unmanaged_writes
|
|
90
|
+
rebuild_if_needed
|
|
91
|
+
savepoint = "factory_hoist_example_#{example.object_id}"
|
|
92
|
+
@transaction.create_savepoint(savepoint)
|
|
93
|
+
@examples_since_begin += 1
|
|
94
|
+
before = DatabaseSnapshot.call(@scopes) if FactoryHoist.configuration.paranoid_mode
|
|
95
|
+
example.run
|
|
96
|
+
after = DatabaseSnapshot.call(@scopes) if before
|
|
97
|
+
if before && before != after
|
|
98
|
+
raise SharedDataMutationError, "paranoid_mode detected changes to hoisted database rows"
|
|
99
|
+
end
|
|
100
|
+
ensure
|
|
101
|
+
@transaction.rollback_savepoint(savepoint) if savepoint
|
|
102
|
+
@transaction.clear_written! if @transaction.owned?
|
|
103
|
+
if local_transaction
|
|
104
|
+
@transaction.rollback_outer
|
|
105
|
+
@examples_since_begin = 0
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def fetch(example_instance, name, fallback, definitions)
|
|
110
|
+
FactoryHoist.stats.increment(:references)
|
|
111
|
+
state = example_instance.instance_variable_get(:@__factory_hoist_values)
|
|
112
|
+
unless state
|
|
113
|
+
state = ExampleValues.new(example_instance, @scopes, definitions)
|
|
114
|
+
example_instance.instance_variable_set(:@__factory_hoist_values, state)
|
|
115
|
+
end
|
|
116
|
+
state.fetch(name, fallback)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def close
|
|
120
|
+
@transaction.rollback_savepoints
|
|
121
|
+
ensure
|
|
122
|
+
@transaction.rollback_outer
|
|
123
|
+
@scopes.clear
|
|
124
|
+
@examples_since_begin = 0
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def preserve_unmanaged_writes
|
|
130
|
+
return unless @transaction.owned? && @transaction.written?
|
|
131
|
+
|
|
132
|
+
# ponytail: nested hook ownership is ambiguous; defer rebuilding all active scopes unless this becomes costly.
|
|
133
|
+
@scopes.each { |scope| scope.rebuildable = false }
|
|
134
|
+
@transaction.clear_written!
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def rebuild_if_needed
|
|
138
|
+
budget = FactoryHoist.configuration.subxid_budget
|
|
139
|
+
return unless @transaction.owned? && @scopes.all?(&:rebuildable) && budget.positive? && @examples_since_begin >= budget
|
|
140
|
+
|
|
141
|
+
@transaction.rollback_outer
|
|
142
|
+
@transaction.begin_outer
|
|
143
|
+
@scopes.each do |scope|
|
|
144
|
+
@transaction.create_savepoint(scope.savepoint)
|
|
145
|
+
scope.materialize!
|
|
146
|
+
end
|
|
147
|
+
@transaction.clear_written!
|
|
148
|
+
@examples_since_begin = 0
|
|
149
|
+
FactoryHoist.stats.increment(:transaction_rebuilds)
|
|
150
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
151
|
+
@transaction.rollback_outer
|
|
152
|
+
@scopes.each { |scope| scope.values.clear }
|
|
153
|
+
raise
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
class Scope
|
|
158
|
+
attr_accessor :rebuildable
|
|
159
|
+
attr_reader :definitions, :group, :values
|
|
160
|
+
|
|
161
|
+
def initialize(group, definitions, ancestors)
|
|
162
|
+
@group = group
|
|
163
|
+
@definitions = definitions
|
|
164
|
+
@ancestors = ancestors.dup
|
|
165
|
+
@values = {}
|
|
166
|
+
@materializing = []
|
|
167
|
+
@rebuildable = true
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def savepoint
|
|
171
|
+
@savepoint ||= "factory_hoist_group_#{group.object_id}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def materialize!
|
|
175
|
+
@values = {}
|
|
176
|
+
context = MaterializationContext.new(@ancestors, self)
|
|
177
|
+
@definitions.each_key { |name| materialize_one(name, context) }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def materialize_one(name, context)
|
|
181
|
+
return @values.fetch(name) if @values.key?(name)
|
|
182
|
+
raise Error, "circular hoist dependency: #{(@materializing + [name]).join(' -> ')}" if @materializing.include?(name)
|
|
183
|
+
|
|
184
|
+
definition = @definitions.fetch(name)
|
|
185
|
+
@materializing << name
|
|
186
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
187
|
+
@values[name] = definition.materialize(context)
|
|
188
|
+
FactoryHoist.stats.increment(:materializations)
|
|
189
|
+
FactoryHoist.stats.record_cost(
|
|
190
|
+
"#{definition.node_path} #{name}",
|
|
191
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
192
|
+
)
|
|
193
|
+
@values.fetch(name)
|
|
194
|
+
ensure
|
|
195
|
+
@materializing.pop if @materializing.last == name
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
class MaterializationContext
|
|
200
|
+
def initialize(ancestors, current)
|
|
201
|
+
@scopes = ancestors + [current]
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def [](name)
|
|
205
|
+
__factory_hoist_fetch__(name)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def __factory_hoist_evaluate__(&block)
|
|
209
|
+
instance_exec(&block)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def method_missing(name, ...)
|
|
213
|
+
return __factory_hoist_fetch__(name) if __factory_hoist_available?(name)
|
|
214
|
+
|
|
215
|
+
super
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def respond_to_missing?(name, include_private = false)
|
|
219
|
+
__factory_hoist_available?(name) || super
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
private
|
|
223
|
+
|
|
224
|
+
def __factory_hoist_available?(name)
|
|
225
|
+
@scopes.reverse_each.any? { |scope| scope.values.key?(name) || scope.definitions.key?(name) }
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def __factory_hoist_fetch__(name)
|
|
229
|
+
scope = @scopes.reverse_each.find { |candidate| candidate.values.key?(name) }
|
|
230
|
+
if scope
|
|
231
|
+
definition = scope.definitions.fetch(name)
|
|
232
|
+
FactoryHoist.stats.record_reference("#{definition.node_path} #{name}")
|
|
233
|
+
return scope.values.fetch(name)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
scope = @scopes.reverse_each.find { |candidate| candidate.definitions.key?(name) }
|
|
237
|
+
if scope
|
|
238
|
+
definition = scope.definitions.fetch(name)
|
|
239
|
+
FactoryHoist.stats.record_reference("#{definition.node_path} #{name}")
|
|
240
|
+
return scope.materialize_one(name, self)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
raise KeyError, "unknown hoist: #{name}"
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
class ExampleValues
|
|
248
|
+
def initialize(example, scopes, definitions)
|
|
249
|
+
@example = example
|
|
250
|
+
@scopes = scopes.dup
|
|
251
|
+
@definitions = definitions
|
|
252
|
+
@values = copy_shared_values
|
|
253
|
+
@local = {}
|
|
254
|
+
@materializing = []
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def fetch(name, fallback = nil)
|
|
258
|
+
definition = @definitions[name] || fallback
|
|
259
|
+
FactoryHoist.stats.record_reference("#{definition.node_path} #{name}") if definition
|
|
260
|
+
return @values.fetch(name) if @values.key?(name)
|
|
261
|
+
return @local.fetch(name) if @local.key?(name)
|
|
262
|
+
|
|
263
|
+
raise KeyError, "unknown hoist: #{name}" unless definition
|
|
264
|
+
if @materializing.include?(name)
|
|
265
|
+
raise Error, "circular local hoist dependency: #{(@materializing + [name]).join(' -> ')}"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
FactoryHoist.stats.increment(:deoptimizations)
|
|
269
|
+
@materializing << name
|
|
270
|
+
begin
|
|
271
|
+
@local[name] = definition.materialize(ExampleMaterializationContext.new(self, @example))
|
|
272
|
+
ensure
|
|
273
|
+
@materializing.pop
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def defined?(name)
|
|
278
|
+
@definitions.key?(name)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
private
|
|
282
|
+
|
|
283
|
+
def copy_shared_values
|
|
284
|
+
copyable = visible_values.select do |_name, value|
|
|
285
|
+
DeepCopy.call(value)
|
|
286
|
+
true
|
|
287
|
+
rescue StandardError
|
|
288
|
+
false
|
|
289
|
+
end
|
|
290
|
+
DeepCopy.call(copyable)
|
|
291
|
+
rescue StandardError
|
|
292
|
+
{}
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def visible_values
|
|
296
|
+
@scopes.each_with_object({}) { |scope, values| values.merge!(scope.values) }
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
class ExampleMaterializationContext
|
|
301
|
+
def initialize(values, example)
|
|
302
|
+
@values = values
|
|
303
|
+
@example = example
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def [](name)
|
|
307
|
+
@values.fetch(name)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def __factory_hoist_evaluate__(&block)
|
|
311
|
+
@example.instance_exec(&block)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def method_missing(name, *args, **kwargs, &block)
|
|
315
|
+
return @values.fetch(name) if args.empty? && kwargs.empty? && @values.defined?(name)
|
|
316
|
+
return @example.__send__(name, *args, **kwargs, &block) if @example.respond_to?(name, true)
|
|
317
|
+
|
|
318
|
+
super
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def respond_to_missing?(name, include_private = false)
|
|
322
|
+
@values.defined?(name) || @example.respond_to?(name, true) || super
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FactoryHoist
|
|
4
|
+
module Scheduler
|
|
5
|
+
extend self
|
|
6
|
+
|
|
7
|
+
def install!
|
|
8
|
+
schedules = Hash.new { |hash, group| hash[group] = {} }
|
|
9
|
+
definitions.each do |group, own_definitions|
|
|
10
|
+
schedules[group]
|
|
11
|
+
own_definitions.each_value do |definition|
|
|
12
|
+
examples = referring_examples(group, definition)
|
|
13
|
+
next if examples.empty?
|
|
14
|
+
|
|
15
|
+
target = lca(examples.map(&:example_group))
|
|
16
|
+
schedules[target][definition.name] = definition
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
schedules.each { |group, scheduled| install_hooks(group, scheduled) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def definitions_for(group)
|
|
23
|
+
group.parent_groups.reverse_each.with_object({}) do |ancestor, available|
|
|
24
|
+
available.merge!(ancestor.instance_variable_get(:@factory_hoist_definitions) || {})
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def definitions
|
|
31
|
+
::RSpec.world.example_groups.flat_map(&:descendants).filter_map do |group|
|
|
32
|
+
own = group.instance_variable_get(:@factory_hoist_definitions)
|
|
33
|
+
[group, own] if own && !own.empty?
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def referring_examples(declaration_group, definition)
|
|
38
|
+
return [] unless hoistable_definition?(declaration_group, definition)
|
|
39
|
+
|
|
40
|
+
declaration_group.descendant_filtered_examples.select do |example|
|
|
41
|
+
available = definitions_for(example.example_group)
|
|
42
|
+
next false unless available[definition.name].equal?(definition)
|
|
43
|
+
|
|
44
|
+
references?(example.instance_variable_get(:@example_block), definition.name) ||
|
|
45
|
+
dependent_definition_references?(example.example_group, definition.name)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def hoistable_definition?(group, definition)
|
|
50
|
+
return false unless hoistable_factory?(definition)
|
|
51
|
+
return true unless definition.block
|
|
52
|
+
return false unless defined?(RubyVM::InstructionSequence)
|
|
53
|
+
|
|
54
|
+
available = definitions_for(group).keys.map(&:to_s)
|
|
55
|
+
disassembly = RubyVM::InstructionSequence.of(definition.block).disasm
|
|
56
|
+
return false if disassembly.include?("getinstancevariable")
|
|
57
|
+
|
|
58
|
+
implicit_calls = disassembly.scan(
|
|
59
|
+
/mid:([^,\s]+), argc:\d+, [^>]*(?:FCALL|VCALL)/
|
|
60
|
+
).flatten
|
|
61
|
+
(implicit_calls - available).empty?
|
|
62
|
+
rescue StandardError
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def hoistable_factory?(definition)
|
|
67
|
+
return true if FactoryHoist.configuration.factory_adapter
|
|
68
|
+
|
|
69
|
+
factory = ::FactoryBot::Internal.factory_by_name(definition.factory).with_traits(definition.traits)
|
|
70
|
+
factory.compile
|
|
71
|
+
factory.definition.constructor.nil? && factory.definition.to_create.nil?
|
|
72
|
+
rescue KeyError
|
|
73
|
+
true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def dependent_definition_references?(group, name)
|
|
77
|
+
available = definitions_for(group)
|
|
78
|
+
directly_referenced = referenced_names(group, available)
|
|
79
|
+
directly_referenced.any? { |referenced| depends_on?(available, referenced, name, {}) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def referenced_names(group, available)
|
|
83
|
+
group.filtered_examples.flat_map do |example|
|
|
84
|
+
available.keys.select { |name| references?(example.instance_variable_get(:@example_block), name) }
|
|
85
|
+
end.uniq
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def depends_on?(available, current, target, seen)
|
|
89
|
+
return true if current == target
|
|
90
|
+
return false if seen[current]
|
|
91
|
+
|
|
92
|
+
seen[current] = true
|
|
93
|
+
definition = available[current]
|
|
94
|
+
return false unless definition&.block
|
|
95
|
+
|
|
96
|
+
available.keys.any? do |name|
|
|
97
|
+
references?(definition.block, name, symbols: true) && depends_on?(available, name, target, seen)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def references?(block, name, symbols: false)
|
|
102
|
+
# ponytail: MRI bytecode finds direct calls cheaply; unsupported/dynamic calls deopt locally.
|
|
103
|
+
return true unless defined?(RubyVM::InstructionSequence)
|
|
104
|
+
|
|
105
|
+
disassembly = RubyVM::InstructionSequence.of(block)&.disasm.to_s
|
|
106
|
+
disassembly.include?("mid:#{name}, argc:0") || (symbols && disassembly.include?(":#{name}"))
|
|
107
|
+
rescue StandardError
|
|
108
|
+
true
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def lca(groups)
|
|
112
|
+
groups.first.parent_groups.find { |candidate| groups.all? { |group| group.parent_groups.include?(candidate) } }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def install_hooks(group, definitions)
|
|
116
|
+
group.prepend_before(:context) do
|
|
117
|
+
next unless self.class.equal?(group)
|
|
118
|
+
|
|
119
|
+
Runtime.current.enter(group, definitions, materialize: false)
|
|
120
|
+
end
|
|
121
|
+
group.before(:context) do
|
|
122
|
+
next unless self.class.equal?(group)
|
|
123
|
+
|
|
124
|
+
Runtime.current.materialize(group)
|
|
125
|
+
end
|
|
126
|
+
group.append_after(:context) do
|
|
127
|
+
next unless self.class.equal?(group)
|
|
128
|
+
|
|
129
|
+
Runtime.current.leave(group)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FactoryHoist
|
|
4
|
+
class Stats
|
|
5
|
+
COUNTERS = %i[deoptimizations materializations references transaction_rebuilds].freeze
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@mutex = Mutex.new
|
|
9
|
+
reset!
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def increment(counter, amount = 1)
|
|
13
|
+
raise ArgumentError, "unknown counter: #{counter}" unless COUNTERS.include?(counter)
|
|
14
|
+
|
|
15
|
+
@mutex.synchronize { @counters[counter] += amount }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def record_cost(key, seconds)
|
|
19
|
+
@mutex.synchronize { @costs[key.to_s] += seconds }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def record_reference(key)
|
|
23
|
+
@mutex.synchronize { @references[key.to_s] += 1 }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_h
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
@counters.merge(
|
|
29
|
+
degradation_rate: degradation_rate,
|
|
30
|
+
reference_counts: @references.sort.to_h,
|
|
31
|
+
materialization_costs: @costs.sort_by { |_, seconds| -seconds }.to_h
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def reset!
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
@counters = COUNTERS.to_h { |counter| [counter, 0] }
|
|
39
|
+
@costs = Hash.new(0.0)
|
|
40
|
+
@references = Hash.new(0)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def degradation_rate
|
|
47
|
+
return 0.0 if @counters[:references].zero?
|
|
48
|
+
|
|
49
|
+
@counters[:deoptimizations].fdiv(@counters[:references])
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FactoryHoist
|
|
4
|
+
module Runtime
|
|
5
|
+
class Transaction
|
|
6
|
+
def initialize
|
|
7
|
+
@connection = nil
|
|
8
|
+
@owned = false
|
|
9
|
+
@savepoints = []
|
|
10
|
+
@written = false
|
|
11
|
+
@write_subscriber = nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def begin_outer
|
|
15
|
+
@connection = active_record_connection
|
|
16
|
+
return unless @connection
|
|
17
|
+
|
|
18
|
+
@owned = !@connection.transaction_open?
|
|
19
|
+
if @owned
|
|
20
|
+
@connection.begin_transaction(joinable: false)
|
|
21
|
+
@write_subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
|
|
22
|
+
@written = true if payload[:connection].equal?(@connection) && @connection.write_query?(payload[:sql])
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def create_savepoint(name)
|
|
28
|
+
return if @savepoints.include?(name)
|
|
29
|
+
|
|
30
|
+
begin_outer unless usable?
|
|
31
|
+
return unless usable?
|
|
32
|
+
|
|
33
|
+
@connection.create_savepoint(name)
|
|
34
|
+
@savepoints << name
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def rollback_savepoint(name)
|
|
38
|
+
return unless usable? && @savepoints.include?(name)
|
|
39
|
+
|
|
40
|
+
@connection.rollback_to_savepoint(name)
|
|
41
|
+
@connection.release_savepoint(name)
|
|
42
|
+
@savepoints.delete(name)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def rollback_savepoints
|
|
46
|
+
rollback_savepoint(@savepoints.last) while usable? && @savepoints.any?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def rollback_outer
|
|
50
|
+
@connection.rollback_transaction if usable? && @owned
|
|
51
|
+
ensure
|
|
52
|
+
ActiveSupport::Notifications.unsubscribe(@write_subscriber) if @write_subscriber
|
|
53
|
+
@write_subscriber = nil
|
|
54
|
+
@written = false
|
|
55
|
+
@savepoints.clear
|
|
56
|
+
@owned = false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def owned?
|
|
60
|
+
@owned
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def written?
|
|
64
|
+
@written
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def clear_written!
|
|
68
|
+
@written = false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def usable?
|
|
74
|
+
@connection && @connection.transaction_open?
|
|
75
|
+
rescue StandardError
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def active_record_connection
|
|
80
|
+
return unless defined?(::ActiveRecord::Base)
|
|
81
|
+
pool = ::ActiveRecord::Base.connection_handler.retrieve_connection_pool(
|
|
82
|
+
::ActiveRecord::Base.connection_specification_name
|
|
83
|
+
)
|
|
84
|
+
return unless pool
|
|
85
|
+
|
|
86
|
+
::ActiveRecord::Base.connection
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|