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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FactoryHoist
4
+ VERSION = "0.1.0"
5
+ end