holdfast 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,1325 @@
1
+ # Runs generated sequences of an app's entry points
2
+ # against real PostgreSQL: random orders (shrunk by Hegel), two actions
3
+ # interleaved at a named pause point, and the same sequence replayed under
4
+ # other process time zones. The hunt stage loads it into the app's test
5
+ # process through SEQUENCES_LIB, so it needs hegeltest in that bundle.
6
+ require "hegel"
7
+ require "json"
8
+
9
+ module Sequences
10
+ # Raised between cases once shrinking has had its share of the time.
11
+ class ShrinkCut < Exception; end # rubocop:disable Lint/InheritException
12
+
13
+ # Seconds shrinking may take after the first failure. A two-step repro
14
+ # found in a second spent the rest of a 150 s area shrinking the world.
15
+ SHRINK_SECONDS = Integer(ENV.fetch("SEQ_SHRINK_SECONDS", "60"))
16
+
17
+ class Failure < StandardError
18
+ attr_reader :log, :definition
19
+
20
+ def initialize(message, log:, definition:)
21
+ @log = log.map(&:dup)
22
+ @definition = definition
23
+ super(message)
24
+ end
25
+
26
+ # The log quotes request params and response text, which can arrive as
27
+ # binary; a message that raises on its own encoding hides the failure.
28
+ def message
29
+ [super, Report.narrative(@log), Report.pasteable(@definition, @log)].map { _1.to_s.dup.force_encoding(Encoding::UTF_8).scrub }.join("\n\n")
30
+ end
31
+ end
32
+
33
+ # The world could not be built, so no sequence ran. Not shrunk.
34
+ class SetupError < StandardError; end
35
+
36
+ # A replayed step whose record is missing: the run went another way.
37
+ class Diverged < StandardError; end
38
+
39
+ # A pause point is a method on a fake (or any class) where an action can be
40
+ # held while another action runs.
41
+ module Pause
42
+ @points = {}
43
+ class << self
44
+ attr_reader :points
45
+
46
+ def define(name, klass, method_name)
47
+ return if @points.key?([name, klass, method_name])
48
+
49
+ @points[[name, klass, method_name]] = true
50
+ hook = Module.new do
51
+ define_method(method_name) do |*args, **kw, &blk|
52
+ gate = Thread.current[:sequences_gate]
53
+ Sequences.reached_outside_call(Thread.current[:sequences_action], klass)
54
+ if gate && gate[:point] == name && !gate[:passed]
55
+ gate[:passed] = true
56
+ gate[:paused].push(true)
57
+ gate[:go].pop
58
+ end
59
+ super(*args, **kw, &blk)
60
+ end
61
+ end
62
+ klass.prepend(hook)
63
+ end
64
+ end
65
+ end
66
+
67
+ # `pause_at: :batch_load` holds a job right after its first query that
68
+ # returns rows (a find_each batch, a `to_a`), before it touches any of them:
69
+ # the window where a desk action changes a row the job already holds.
70
+ module BatchPause
71
+ # Only the outermost load pauses: `includes(:copy)` loads the copies
72
+ # inside the transfers' load, and holding there held the job on the
73
+ # copies it preloads, not on the rows it acts on.
74
+ def records
75
+ depth = Thread.current[:sequences_load_depth].to_i
76
+ Thread.current[:sequences_load_depth] = depth + 1
77
+ rows = begin
78
+ super
79
+ ensure
80
+ Thread.current[:sequences_load_depth] = depth
81
+ end
82
+ gate = Thread.current[:sequences_gate]
83
+ if gate && gate[:point] == :batch_load && !gate[:passed] && depth.zero? && rows.any?
84
+ gate[:passed] = true
85
+ gate[:relation] = self
86
+ gate[:loaded] = rows.select { it.is_a?(ActiveRecord::Base) }.map { [it.class, it.id, Sequences.state_of(it)] }
87
+ gate[:paused].push(true)
88
+ gate[:go].pop
89
+ end
90
+ rows
91
+ end
92
+ end
93
+ ActiveRecord::Relation.prepend(BatchPause) if defined?(ActiveRecord::Relation)
94
+
95
+ STATE_COLUMNS = %w[status state].freeze
96
+ def self.state_of(record) = STATE_COLUMNS.find { record.has_attribute?(it) }&.then { [it, record[it]] }
97
+
98
+ Action = Struct.new(:name, :params, :pause_at, :weight, :block, :failures, :fresh, keyword_init: true)
99
+
100
+ # An end drawn from its start: `ends_at: after(:starts_at, minutes: [...])`.
101
+ # Drawn apart, nearly every pair broke a duration rule and the action was
102
+ # only ever refused.
103
+ After = Data.define(:from, :minutes) do
104
+ def inspect = "after(:#{from}, minutes: #{minutes.inspect})"
105
+ end
106
+
107
+ class Definition
108
+ attr_reader :name, :actions, :invariants, :refusal_classes, :pools, :zones, :clock_start, :ignore_columns, :tick_weight,
109
+ :skipped, :failures, :twin_pairs
110
+
111
+ def initialize(name, shared: true)
112
+ @name = name
113
+ @actions = {}
114
+ @invariants = {}
115
+ @pools = {}
116
+ @refusal_classes = []
117
+ @zones = []
118
+ @clock_start = nil
119
+ @ignore_columns = %w[created_at updated_at]
120
+ @helpers = Module.new
121
+ @skipped = {}
122
+ @failures = {}
123
+ @twin_pairs = []
124
+ Sequences.shared.each { instance_exec(&it) } if shared
125
+ end
126
+
127
+ def setup(&block) = (@setup = block)
128
+ def run_setup(ctx) = ctx.instance_exec(&@setup)
129
+ def pool(name, &query) = (@pools[name] = query)
130
+ def helpers(&block) = block ? @helpers.module_eval(&block) : @helpers
131
+
132
+ # Every public method of these classes (the fakes of outside services) is
133
+ # a pause point, so any action can be held at its first outside call.
134
+ def outside_calls(*classes)
135
+ Sequences.outside_classes_declared.merge(classes)
136
+ classes.each do |klass|
137
+ klass.public_instance_methods(false).each { Pause.define(:outside_call, klass, it) }
138
+ end
139
+ @default_pause = :outside_call
140
+ end
141
+ def refusals(*classes) = @refusal_classes.concat(classes)
142
+ def invariant(name, &block) = (@invariants[name] = block)
143
+ def ignore(*columns) = @ignore_columns.concat(columns.map(&:to_s))
144
+ def after(from, minutes:) = After.new(from:, minutes:)
145
+
146
+ # Two doors to one effect (a desk route and an API endpoint): run both
147
+ # from the same state with the same inputs; the outcome and the rows of
148
+ # +table+ must match.
149
+ def twins(first, second, table:) = @twin_pairs << [first, second, table.to_s]
150
+
151
+ # Starts the clock and turns on the boundary-biased tick rule and the
152
+ # process time-zone replay.
153
+ def clock(start:, zones: [], weight: 3.0)
154
+ @clock_start = Time.utc(*start)
155
+ @zones = zones
156
+ @tick_weight = weight
157
+ end
158
+
159
+ # An action with a :TODO param does not run; the report lists it with
160
+ # the params to fill, and the rest of the area still runs.
161
+ def action(name, pause_at: nil, weight: 1.0, failures: [], fresh: [], **params, &block)
162
+ todo = params.select { |_, spec| spec == :TODO }.keys
163
+ return @skipped[name] = "replace :TODO for #{todo.join(", ")}" if todo.any?
164
+
165
+ @actions[name] = Action.new(name:, params:, pause_at:, weight:, block:, failures:, fresh:)
166
+ end
167
+
168
+ # How an outside service fails: the block arms the fake. An action that
169
+ # lists the failure runs once more as its own step with the fake armed.
170
+ def failure(name, &block) = (@failures[name] = block)
171
+
172
+ def pausable = @actions.values.select { it.pause_at || @default_pause }
173
+ def pause_point(action) = action.pause_at || @default_pause
174
+ end
175
+
176
+ def self.define(name, &block)
177
+ definition = Definition.new(name)
178
+ definition.instance_exec(&block)
179
+ registry[name] = definition
180
+ end
181
+
182
+ def self.registry = (@registry ||= {})
183
+ def self.[](name) = registry.fetch(name)
184
+
185
+ # Days and hours the tick rule jumps to: the edges of a day in each zone,
186
+ # month ends, and the 2026 daylight-saving changes.
187
+ # Mid-day times too: with only day edges, an action that needs opening
188
+ # hours was refused at every draw.
189
+ BOUNDARY_TIMES = [[0, 0, 0], [0, 0, 1], [23, 59, 59], [12, 0, 0], [1, 30, 0], [9, 30, 0], [15, 0, 0]].freeze
190
+ # The other ways to write the same instant, for the offset replay.
191
+ OFFSETS = ["Z", "-08:00", "+13:00"].freeze
192
+ ZONES_FOR_DAY_EDGES = ["UTC", "Pacific/Auckland", "America/Los_Angeles", "Asia/Tokyo"].freeze
193
+ TEXTS = ["Sample text", "", "Ä 漢字 #{"x" * 40}"].freeze
194
+
195
+ # What an action body sees as self: the world helpers and an HTTP session.
196
+ class Context
197
+ include ActiveSupport::Testing::TimeHelpers
198
+ include FactoryBot::Syntax::Methods
199
+
200
+ attr_reader :definition
201
+ attr_accessor :sizer
202
+
203
+ # How many of a record the world starts with, drawn per case (and shrunk).
204
+ def world_size(name, range) = @sizer ? @sizer.call(name, range) : range.max
205
+
206
+ def initialize(definition)
207
+ @definition = definition
208
+ extend(definition.helpers)
209
+ end
210
+
211
+ def http
212
+ Thread.current[:sequences_http] ||= ActionDispatch::Integration::Session.new(Rails.application)
213
+ end
214
+
215
+ def records(pool) = @definition.pools.fetch(pool).call.to_a
216
+ def pool?(name) = @definition.pools.key?(name)
217
+
218
+ # A factory call that fails in `setup` names itself and its line.
219
+ %i[create create_list build].each do |factory_call|
220
+ define_method(factory_call) do |*args, **kw, &blk|
221
+ super(*args, **kw, &blk)
222
+ rescue SetupError
223
+ raise
224
+ rescue StandardError => e
225
+ where = caller_locations.find { it.path.start_with?(Rails.root.to_s) && !it.path.start_with?(__dir__) && !it.path.include?("/spec/support/") }
226
+ # A record's whole inspect pushed the error itself off the one line a
227
+ # report shows, so a record is named by its class and id.
228
+ show = ->(v) { v.is_a?(ActiveRecord::Base) ? "#<#{v.class.name} #{v.id || "new"}>" : v.inspect }
229
+ call = (args.map(&show) + kw.map { |k, v| "#{k}: #{show.(v)}" }).join(", ")
230
+ raise SetupError, "#{factory_call}(#{call}) at #{where&.path&.sub("#{Rails.root}/", "")}:#{where&.lineno} failed: #{e.class}: #{e.message}"
231
+ end
232
+ end
233
+ end
234
+
235
+ # Draws parameter values (or reads them back from a log on replay).
236
+ class Draw
237
+ def initialize(tc) = (@tc = tc)
238
+
239
+ def index(size) = int(0..size - 1)
240
+ def int(range) = @tc.draw(Hegel::Generators::IntegerGenerator.new(min_value: range.min, max_value: range.max))
241
+ def assume(ok) = @tc.assume(ok)
242
+ end
243
+
244
+ class Runner
245
+ attr_reader :log
246
+
247
+ def initialize(definition)
248
+ @definition = definition
249
+ @ctx = Context.new(definition)
250
+ @log = []
251
+ @clock = definition.clock_start
252
+ @tracked = {}
253
+ end
254
+
255
+ # Builds the world. +sizes+ replays the world sizes a log recorded; +draw+
256
+ # draws them; with neither, each size is its largest.
257
+ def reset!(sizes: nil, draw: nil)
258
+ Sequences.empty_tables
259
+ FactoryBot.rewind_sequences if defined?(FactoryBot)
260
+ queue = sizes&.dup
261
+ world = []
262
+ @ctx.sizer = lambda do |name, range|
263
+ n = queue ? (queue.shift || range.min) : draw ? draw.int(range) : range.max
264
+ n.clamp(range.min, range.max).tap { world << [name, it] }
265
+ end
266
+ begin
267
+ at_clock { @definition.run_setup(@ctx) }
268
+ rescue SetupError
269
+ raise
270
+ rescue StandardError => e
271
+ where = e.backtrace.find { it.include?("/spec/") && !it.include?("/spec/support/") } || e.backtrace.first
272
+ raise SetupError, "setup failed at #{where.sub("#{Rails.root}/", "")}: #{e.class}: #{e.message}"
273
+ end
274
+ @log = []
275
+ @log << { step: :world, args: world.map(&:last), label: world.map { "#{_1} #{_2}" }.join(", ") } unless world.empty?
276
+ check_invariants("after setup")
277
+ end
278
+
279
+ # Resolves an action's declared params with +draw+ (index into a pool, or
280
+ # an integer), returning both the recorded choice and the value.
281
+ def choose(action, draw)
282
+ choice = {}
283
+ action.params.each do |key, spec|
284
+ case spec
285
+ when :local_time, :local_date
286
+ choice[key] = { zone: draw.int(0..ZONES_FOR_DAY_EDGES.size - 1), days: draw.int(-2..40),
287
+ time: draw.int(0..BOUNDARY_TIMES.size - 1) }
288
+ when :text
289
+ choice[key] = draw.int(0..TEXTS.size - 1)
290
+ when After
291
+ choice[key] = draw.index(spec.minutes.size)
292
+ when Symbol
293
+ rows = @ctx.records(spec)
294
+ draw.assume(!rows.empty?)
295
+ choice[key] = draw.index(rows.size)
296
+ when Range
297
+ choice[key] = draw.int(spec)
298
+ when Array
299
+ choice[key] = draw.index(spec.size)
300
+ end
301
+ end
302
+ choice
303
+ end
304
+
305
+ # +offset+ writes every :local_time value as the same instant in another
306
+ # UTC offset ("Z", "-08:00", ...).
307
+ def resolve(action, choice, offset: nil)
308
+ action.params.each_with_object({}) do |(key, spec), out|
309
+ out[key] = case spec
310
+ when :local_time
311
+ time = local_time(choice[key])
312
+ offset.nil? ? time : offset == "Z" ? time.utc : time.getlocal(offset)
313
+ when :local_date then local_time(choice[key]).to_date
314
+ when :text then TEXTS.fetch(choice[key])
315
+ when After then out.fetch(spec.from) + spec.minutes.fetch(choice[key]).minutes
316
+ when Symbol
317
+ rows = @ctx.records(spec)
318
+ rows.fetch(choice[key]) { raise Diverged, "step #{@log.size + 1} (#{action.name}) needs #{spec} row #{choice[key] + 1}, and there are #{rows.size}" }
319
+ when Array then spec.fetch(choice[key])
320
+ else choice[key]
321
+ end
322
+ end
323
+ end
324
+
325
+ # A wall-clock time in one of the zones, at a day edge, some days from now:
326
+ # the input a member in another time zone would send.
327
+ def local_time(choice)
328
+ zone = ActiveSupport::TimeZone[ZONES_FOR_DAY_EDGES.fetch(choice[:zone])]
329
+ day = (@clock || Time.current).in_time_zone(zone).to_date + choice[:days]
330
+ h, m, sec = BOUNDARY_TIMES.fetch(choice[:time])
331
+ zone.local(day.year, day.month, day.day, h, m, sec)
332
+ end
333
+
334
+ def describe(action, choice, offset: nil)
335
+ resolve(action, choice, offset:).map do |k, v|
336
+ "#{k}=#{v.is_a?(ActiveRecord::Base) ? "#{v.class.name}##{v.id}" : v.respond_to?(:iso8601) && !v.is_a?(Date) ? v.iso8601 : v}"
337
+ end.join(" ")
338
+ end
339
+
340
+ # Runs the block and rolls back what it wrote: the rows, the id counters,
341
+ # and the state of the fakes, so the step after it runs as if it had not.
342
+ # The outer transaction is not joinable, so the app's own transactions
343
+ # become savepoints, as under transactional tests.
344
+ def trial
345
+ saved = Sequences.save_outside_state
346
+ result = nil
347
+ ActiveRecord::Base.transaction(joinable: false) do
348
+ result = yield
349
+ raise ActiveRecord::Rollback
350
+ end
351
+ result
352
+ ensure
353
+ Sequences.restore_outside_state(saved) if saved
354
+ end
355
+
356
+ # What a step did, to compare two ways of running it: accepted or refused,
357
+ # and the rows of +tables+ (every table when nil) without ids, times and
358
+ # random tokens.
359
+ def effect(outcome, tables = nil)
360
+ rows = snapshot(tables).transform_values do |list|
361
+ list.map { |row| row.reject { |col, _| col == "id" || col.match?(NOISE_COLUMN) } }
362
+ end
363
+ { accepted: !Sequences.kind(outcome).match?(/refused|HTTP 4/), outcome:, rows: }
364
+ end
365
+
366
+ NOISE_COLUMN = /(\A|_)(token|digest|uuid|nonce|secret|salt|key)\z|_at\z/
367
+
368
+ # The same step with each :local_time param written as the same instant
369
+ # in another UTC offset must answer and write the same.
370
+ def offset_step(name, choice, offset)
371
+ action = @definition.actions.fetch(name)
372
+ label = "#{name}(#{describe(action, choice)}), then the same instant written as #{offset}: #{describe(action, choice, offset:)}"
373
+ entry = { step: :offset, args: { action: name, args: choice, offset: }, at: @clock&.iso8601, label: }
374
+ @log << entry
375
+ at_clock do
376
+ other = trial { effect(perform_with(action, resolve(action, choice, offset:))) }
377
+ mine = effect(perform(action, choice))
378
+ entry[:outcome] = mine[:outcome]
379
+ Sequences.tally(:"#{name} [other offset]", mine[:outcome])
380
+ compare!("the same instant in another UTC offset changed the result", mine, other, "as drawn", "as #{offset}")
381
+ end
382
+ check_invariants("after #{name} [other offset]")
383
+ end
384
+
385
+ # A desk action and an API action that make the same record, from the
386
+ # same state with the same inputs, must agree.
387
+ def twins_step(first_name, first_choice, second_name, second_choice, table)
388
+ first = @definition.actions.fetch(first_name)
389
+ second = @definition.actions.fetch(second_name)
390
+ label = "#{first_name}(#{describe(first, first_choice)}) vs #{second_name}(#{describe(second, second_choice)}) from the same state"
391
+ entry = { step: :twins, args: { first: first_name, first_args: first_choice, second: second_name, second_args: second_choice, table: },
392
+ at: @clock&.iso8601, label: }
393
+ @log << entry
394
+ at_clock do
395
+ one = trial { effect(perform(first, first_choice), [table]) }
396
+ two = effect(perform(second, second_choice), [table])
397
+ entry[:outcome] = "#{first_name} => #{one[:outcome]}; #{second_name} => #{two[:outcome]}"
398
+ # A door that turned the caller away (not signed in, no token) never
399
+ # reached the code; the pair compares nothing, and the report says
400
+ # the pair was not reached.
401
+ if [one, two].any? { Sequences.turned_away?(it[:outcome]) }
402
+ Sequences.tally(:"#{first_name} = #{second_name}", "refused: turned away")
403
+ next
404
+ end
405
+ # A column only one door takes a param for (the desk's notes) is
406
+ # that door's own, so the pair compares the columns both can set. An
407
+ # option only one door names (the desk's `when`, the API's `now`)
408
+ # cannot be given the same value on both, so the pair compares nothing.
409
+ a_keys, b_keys = first.params.keys.map(&:to_s), second.params.keys.map(&:to_s)
410
+ unmatched = ((a_keys - b_keys) | (b_keys - a_keys))
411
+ columns = ActiveRecord::Base.connection.columns(table.to_s).map(&:name)
412
+ options = unmatched.reject { |k| columns.include?(k) || [first, second].any? { |a| (spec = a.params[k.to_sym]).is_a?(Symbol) && @ctx.pool?(spec) } }
413
+ if options.any?
414
+ Sequences.tally(:"#{first_name} = #{second_name}", "refused: option on one door only (#{options.join(", ")})")
415
+ next
416
+ end
417
+ # One door picks the record from a pool (the desk's member) where the
418
+ # other acts for whoever signed in: the two act on different records.
419
+ pools = [first, second].map { |a| a.params.reject { |k, _| k == :caller }.values.select { it.is_a?(Symbol) && @ctx.pool?(it) }.uniq }
420
+ alone = (pools[0] - pools[1]) | (pools[1] - pools[0])
421
+ if alone.any?
422
+ Sequences.tally(:"#{first_name} = #{second_name}", "refused: only one door picks from #{alone.join(", ")}")
423
+ next
424
+ end
425
+ # One door makes a new value (a unique email) where the other draws
426
+ # one that exists: a refusal on one side is the input, not the code.
427
+ fresh = (Array(first.fresh) + Array(second.fresh)).map(&:to_s) & unmatched.map { it.delete_suffix("_from") }
428
+ if fresh.any?
429
+ Sequences.tally(:"#{first_name} = #{second_name}", "refused: one door makes a new #{fresh.join(", ")}, the other draws one")
430
+ next
431
+ end
432
+ Sequences.tally(:"#{first_name} = #{second_name}", one[:accepted] || two[:accepted] ? "ok" : "refused: both")
433
+ # Rows only: a desk can answer a refusal with a notice where the API
434
+ # answers 409, and both wrote nothing.
435
+ # A column a door fills with a new value per call (`fresh:`, a
436
+ # unique email) differs by construction.
437
+ own = (unmatched.map { it.delete_suffix("_from") } + Array(first.fresh) + Array(second.fresh)).to_set { "#{table}.#{it}" }
438
+ compare!("two doors to #{table} disagree", one, two, first_name, second_name, outcome: false, noise: own)
439
+ end
440
+ check_invariants("after #{first_name} = #{second_name}")
441
+ end
442
+
443
+ # The second door's inputs: a param of the same name and kind, or the one
444
+ # param of the first that draws from the same pool, takes the same choice.
445
+ # A pool param with no match (the API's caller) takes the owner of a
446
+ # record the first door was given: the API acts as that record's member.
447
+ def same_inputs(first, first_choice, second, draw)
448
+ fresh = choose(second, draw)
449
+ values = resolve(first, first_choice)
450
+ given = values.values.grep(ActiveRecord::Base)
451
+ second.params.to_h do |key, spec|
452
+ # Same name, other draw list (attendees 0..5 and [0, 1, 2, 4, 12]), or
453
+ # an end drawn from the start where the desk takes minutes: send the
454
+ # same value, or skip this draw when the second door cannot.
455
+ same = first.params.key?(key) ? key : (first.params.keys.find { it.to_s.match?(/minutes|duration/) } if spec.is_a?(After))
456
+ if same && first.params[same] != spec
457
+ mapped = choice_for(spec, values[same], values)
458
+ draw.assume(!mapped.nil?)
459
+ next [key, mapped]
460
+ end
461
+ from = first.params[key] == spec ? key : (first.params.select { |_, s| s == spec && s.is_a?(Symbol) }.keys.then { it.one? ? it.first : nil })
462
+ next [key, first_choice[from]] if from
463
+ # A symbol is a pool, or a kind of value such as :local_date.
464
+ next [key, fresh[key]] unless spec.is_a?(Symbol) && @ctx.pool?(spec)
465
+
466
+ rows = @ctx.records(spec)
467
+ owner = given.flat_map do |record|
468
+ record.class.reflect_on_all_associations(:belongs_to).reject(&:polymorphic?)
469
+ .select { rows.first.is_a?(it.klass) }.map { record.public_send(it.foreign_key) }
470
+ end.first
471
+ at = owner && rows.index { it.id == owner }
472
+ [key, at || fresh[key]]
473
+ end
474
+ end
475
+
476
+ def choice_for(spec, value, values)
477
+ case spec
478
+ when Range then value if value.is_a?(Integer) && spec.cover?(value)
479
+ when Array then spec.index(value)
480
+ when After
481
+ minutes = value.is_a?(Integer) ? value : ((value - values.fetch(spec.from)) / 60).round
482
+ spec.minutes.index(minutes)
483
+ end
484
+ end
485
+
486
+ def compare!(what, a, b, a_name, b_name, outcome: true, noise: Set.new)
487
+ if outcome && a[:accepted] != b[:accepted]
488
+ raise Failure.new("#{what}: #{a_name} #{a[:accepted] ? "accepted" : "refused"} (#{a[:outcome]}), #{b_name} #{b[:accepted] ? "accepted" : "refused"} (#{b[:outcome]})",
489
+ log: @log, definition: @definition)
490
+ end
491
+ diff = Sequences.diff(a[:rows], b[:rows], noise)
492
+ return if diff.empty?
493
+
494
+ raise Failure.new("#{what}: rows differ (#{a_name} => #{a[:outcome]}; #{b_name} => #{b[:outcome]})\n#{diff.first(6).map { it[:text] }.join("\n")}",
495
+ log: @log, definition: @definition)
496
+ end
497
+
498
+ def step(name, choice, failure: nil, again: false)
499
+ action = @definition.actions.fetch(name)
500
+ entry = { step: name, args: choice, at: @clock&.iso8601, label: describe(action, choice) }
501
+ entry.merge!(failure:, label: "[#{failure}] #{entry[:label]}") if failure
502
+ entry[:label] = "again, as sent before: #{entry[:label]}" if again
503
+ @log << entry
504
+ $stderr.puts " step #{entry[:step]} #{entry[:label]}" if ENV["SEQ_DEBUG"]
505
+ entry[:outcome] = at_clock do
506
+ @ctx.instance_exec(&@definition.failures.fetch(failure)) if failure
507
+ perform(action, choice)
508
+ end
509
+ Sequences.tally(failure ? :"#{name} [#{failure}]" : name, entry[:outcome])
510
+ check_invariants("after #{name}")
511
+ end
512
+
513
+ # Steps the app accepted, which a double click or a retried request
514
+ # would send once more.
515
+ def accepted_steps
516
+ @log.select { |e| @definition.actions.key?(e[:step]) && e[:outcome] && !Sequences.kind(e[:outcome]).match?(/refused|HTTP 4/) }
517
+ end
518
+
519
+ # Dates and times the app stored (due dates, deadlines, pauses), where a
520
+ # day-boundary bug would show.
521
+ # One query over every date column: a query per column on each tick
522
+ # took a quarter of the run.
523
+ def stored_dates
524
+ conn = ActiveRecord::Base.connection
525
+ @dates_sql ||= (conn.tables - %w[schema_migrations ar_internal_metadata]).flat_map do |table|
526
+ conn.columns(table).select { %i[date datetime].include?(it.type) && !@definition.ignore_columns.include?(it.name) }.map do |column|
527
+ "SELECT DISTINCT CAST(#{conn.quote_column_name(column.name)} AS date) AS d FROM #{conn.quote_table_name(table)} WHERE #{conn.quote_column_name(column.name)} IS NOT NULL"
528
+ end
529
+ end.join(" UNION ")
530
+ return [] if @dates_sql.empty?
531
+
532
+ conn.select_values(@dates_sql).map { it.respond_to?(:to_date) ? it.to_date : Date.parse(it.to_s) }.uniq.sort
533
+ end
534
+
535
+ def tick(choice)
536
+ zone = ActiveSupport::TimeZone[ZONES_FOR_DAY_EDGES.fetch(choice[:zone])]
537
+ dates = choice[:stored] ? stored_dates : []
538
+ local = if dates.empty? then @clock.in_time_zone(zone).to_date + choice[:days]
539
+ else dates.fetch(choice[:stored] % dates.size) + (choice[:days] % 3) - 1
540
+ end
541
+ h, m, s = BOUNDARY_TIMES.fetch(choice[:time])
542
+ target = zone.local(local.year, local.month, local.day, h, m, s).utc
543
+ target = @clock + 1 if target <= @clock
544
+ @log << { step: :tick, args: choice, at: @clock&.iso8601, label: "clock -> #{target.iso8601} (#{zone.name} #{target.in_time_zone(zone).strftime("%F %T")})" }
545
+ @clock = target
546
+ end
547
+
548
+ # Runs +first+ until it reaches its pause point, then runs +second+ to the
549
+ # end (or until it blocks on a database lock), then lets +first+ finish.
550
+ def interleave(first_name, first_choice, second_name, second_choice)
551
+ first = @definition.actions.fetch(first_name)
552
+ second = @definition.actions.fetch(second_name)
553
+ entry = { step: :interleave, args: { first: first_name, first_args: first_choice, second: second_name, second_args: second_choice },
554
+ at: @clock&.iso8601,
555
+ label: "#{first_name}(#{describe(first, first_choice)}) paused at #{@definition.pause_point(first)}, then #{second_name}(#{describe(second, second_choice)})" }
556
+ @log << entry
557
+ $stderr.puts " step #{entry[:step]} #{entry[:label]}" if ENV["SEQ_DEBUG"]
558
+ # Records are resolved on the main thread, before either action runs.
559
+ first_label, second_label = describe(first, first_choice), describe(second, second_choice)
560
+ first_args = resolve(first, first_choice)
561
+ second_args = resolve(second, second_choice)
562
+ gate = { point: @definition.pause_point(first), paused: Queue.new, go: Queue.new, passed: false }
563
+ outcomes = {}
564
+ at_clock do
565
+ t1 = Thread.new do
566
+ Thread.current[:sequences_gate] = gate
567
+ ActiveRecord::Base.connection_pool.with_connection { outcomes[:first] = perform_with(first, first_args) }
568
+ ensure
569
+ gate[:paused].push(:done)
570
+ end
571
+ reached = gate[:paused].pop
572
+ second_args = onto_loaded(second_args, gate[:loaded]) if reached == true && gate[:loaded]
573
+ t2 = Thread.new do
574
+ ActiveRecord::Base.connection_pool.with_connection { outcomes[:second] = perform_with(second, second_args) }
575
+ end
576
+ blocked = t2.join(0.5).nil?
577
+ between = !blocked && reached == true && gate[:loaded] ? current_states(gate[:loaded]) : nil
578
+ gate[:go].push(true)
579
+ [t1, t2].each { raise Failure.new("deadlock or hang in interleaving", log: @log, definition: @definition) unless it.join(10) }
580
+ $stderr.puts " interleave reached=#{reached.inspect} blocked=#{blocked} second=#{outcomes[:second].inspect} loaded=#{gate[:loaded].inspect} between=#{between.inspect}" if ENV["SEQ_DEBUG"] && reached == true
581
+ stale_overwrite!(first_name, second_name, gate, between, outcomes[:second]) if between
582
+ entry[:outcome] = { first: outcomes[:first], second: outcomes[:second],
583
+ paused: reached == true, second_blocked: blocked }
584
+ Sequences.tally(:"interleave, first #{first_name}", reached == true ? (blocked ? "paused, second waited on a lock" : "paused") : "no outside call")
585
+ # The first action never reached an outside call, so the two ran one
586
+ # after the other. The entry stays an interleave so the repro resolves
587
+ # both records before either runs, as this run did.
588
+ unless reached == true
589
+ entry[:label] = "#{first_name}(#{first_label}), then #{second_name}(#{second_label}); no outside call, so no pause"
590
+ entry[:outcome] = { first: outcomes[:first], second: outcomes[:second] }
591
+ end
592
+ end
593
+ check_invariants("after interleave")
594
+ end
595
+
596
+ # A job held after its batch load races only on the rows it loaded, so a
597
+ # second action's record of the same class is moved onto one of them
598
+ # (the first loaded, so a replay picks the same row).
599
+ def onto_loaded(args, loaded)
600
+ args.to_h do |key, value|
601
+ row = value.is_a?(ActiveRecord::Base) && loaded.none? { |k, id, _| value.is_a?(k) && value.id == id } &&
602
+ loaded.find { |k, _, _| value.is_a?(k) }
603
+ [key, row ? row[0].unscoped.find_by(id: row[1]) || value : value]
604
+ end
605
+ end
606
+
607
+ def current_states(loaded)
608
+ loaded.to_h { |klass, id, _| [[klass, id], klass.unscoped.find_by(id:)&.then { Sequences.state_of(it) }] }
609
+ end
610
+
611
+ # A job that loaded a row before another action set its status must not
612
+ # write a status over it that the job's own query would not have picked
613
+ # the row for: that is the job acting on what it read, not on what is
614
+ # there. The query is asked inside a rolled-back trial, with the row put
615
+ # back at the status the other action set.
616
+ def stale_overwrite!(job, other, gate, between, other_outcome)
617
+ return if Sequences.kind(other_outcome).match?(/refused|HTTP 4/)
618
+
619
+ gate[:loaded].each do |klass, id, (column, loaded)|
620
+ next unless column
621
+
622
+ set = between[[klass, id]]&.last
623
+ now = klass.unscoped.find_by(id:)&.[](column)
624
+ next if set.nil? || set == loaded || now == set
625
+
626
+ picked = trial do
627
+ klass.unscoped.where(id:).update_all(column => set)
628
+ gate[:relation].except(:limit, :offset, :order).where(id:).exists?
629
+ end
630
+ next if picked
631
+
632
+ raise Failure.new("#{job} loaded #{klass.name}##{id} with #{column} #{loaded.inspect}; #{other} set it to #{set.inspect} " \
633
+ "while #{job} held it; #{job} then wrote #{now.inspect}, though its own query does not select a row whose #{column} is #{set.inspect}",
634
+ log: @log, definition: @definition)
635
+ end
636
+ end
637
+
638
+ def perform(action, choice) = perform_with(action, resolve(action, choice))
639
+
640
+ def perform_with(action, args)
641
+ Thread.current[:sequences_action] = action.name
642
+ result = begin
643
+ @ctx.instance_exec(**args, &action.block)
644
+ ensure
645
+ Thread.current[:sequences_action] = nil
646
+ end
647
+ return "ok" unless result.is_a?(Integer) && result.between?(100, 599)
648
+ raise Failure.new("#{action.name} answered HTTP #{result}", log: @log, definition: @definition) if result >= 500
649
+
650
+ # A flash set by the request before is still readable here, marked for
651
+ # discard; only an alert this request set counts.
652
+ flash = @ctx.http.flash if result.between?(300, 399)
653
+ alert = flash[:alert] if flash && !flash.instance_variable_get(:@discard).include?("alert")
654
+ body = @ctx.http.response&.body.to_s.dup.force_encoding(Encoding::UTF_8).scrub if result >= 400
655
+ # The plain error page says only "400 Bad Request"; the exception the
656
+ # request raised names the param.
657
+ raised = @ctx.http.request&.env&.[]("action_dispatch.exception") if result >= 400
658
+ body = raised.respond_to?(:param) ? "missing param #{raised.param}" : "#{raised.class.name}: #{raised.message}" if raised
659
+ # A form rendered again says why in its record's errors; its title
660
+ # names only the layout. Another HTML error page says it in its title.
661
+ if body&.lstrip&.start_with?("<")
662
+ errors = invalid_record&.errors&.full_messages
663
+ body = errors&.any? ? errors.first(2).join("; ") : (body[%r{<title>(.*?)</title>}m, 1] || body)
664
+ end
665
+ alert ||= body.strip[0, 80] if body && !body.strip.empty?
666
+ alert ? "HTTP #{result} refused: #{alert}" : "HTTP #{result}"
667
+ rescue *@definition.refusal_classes => e
668
+ "refused: #{e.class.name}: #{e.message}"
669
+ rescue Failure
670
+ raise
671
+ rescue StandardError => e
672
+ raise Failure.new("#{action.name} raised #{e.class}: #{e.message}\n #{e.backtrace.grep(%r{/app/}).first(3).join("\n ")}", log: @log, definition: @definition)
673
+ end
674
+
675
+ def invalid_record
676
+ controller = @ctx.http.controller or return nil
677
+ controller.instance_variables.lazy.map { controller.instance_variable_get(it) }
678
+ .find { it.respond_to?(:errors) && it.errors.respond_to?(:full_messages) && it.errors.any? }
679
+ end
680
+
681
+ def at_clock(&block)
682
+ return yield unless @clock
683
+
684
+ @ctx.travel_to(@clock, &block)
685
+ end
686
+
687
+ def check_invariants(where)
688
+ Thread.current[:sequences_runner] = self
689
+ @definition.invariants.each do |name, block|
690
+ ok = at_clock { @ctx.instance_exec(&block) }
691
+ next if ok == true || ok.nil?
692
+
693
+ raise Failure.new("invariant #{name} broken #{where}: #{ok.inspect}", log: @log, definition: @definition)
694
+ end
695
+ end
696
+
697
+ # Every row of every table, minus the columns the definition ignores, so
698
+ # two runs of the same steps can be compared.
699
+ # Rows that leave +from+ for one of +to+ leave it in the order they
700
+ # entered it, as this run saw them enter (not as a column says). Only rows
701
+ # alike in every column but ids and times are compared: any other
702
+ # column (a party size, a pause date) can be a reason to skip a row.
703
+ def order_kept(model, from:, to:, group: nil)
704
+ table = model.table_name
705
+ before = @tracked[table]
706
+ now = model.unscoped.order(:id).map(&:attributes)
707
+ @tracked[table] = now.to_h do |row|
708
+ was = before&.[](row["id"])
709
+ [row["id"], { status: row["status"], since: was && was[:status] == row["status"] ? was[:since] : @log.size, row: }]
710
+ end
711
+ return true unless before
712
+
713
+ group = group&.to_s
714
+ alike = model.column_names.reject { it == "id" || it == "status" || it.end_with?("_at") || (it.end_with?("_id") && it != group) || @definition.ignore_columns.include?(it) }
715
+ broken = before.filter_map do |id, was|
716
+ moved = @tracked[table][id]
717
+ next unless was[:status] == from && moved && Array(to).include?(moved[:status])
718
+
719
+ skipped = @tracked[table].find do |other_id, other|
720
+ other_id != id && other[:status] == from && other[:since] < was[:since] &&
721
+ (group.nil? || other[:row][group] == was[:row][group]) && alike.all? { other[:row][it] == was[:row][it] }
722
+ end
723
+ next unless skipped
724
+
725
+ "#{table} #{id} (#{from} since step #{was[:since]}) became #{moved[:status]} while #{table} #{skipped[0]} " \
726
+ "(#{from} since step #{skipped[1][:since]}, alike in #{alike.join(", ").presence || "every column"}) still waits"
727
+ end
728
+ broken.empty? || broken.join("; ")
729
+ end
730
+
731
+ def snapshot(only = nil)
732
+ conn = ActiveRecord::Base.connection
733
+ (only || (conn.tables - %w[schema_migrations ar_internal_metadata])).sort.to_h do |table|
734
+ rows = conn.select_all("SELECT * FROM #{conn.quote_table_name(table)} ORDER BY id").to_a
735
+ [table, rows.map { |row| row.except(*@definition.ignore_columns) }]
736
+ end
737
+ end
738
+
739
+ def replay(entries)
740
+ entries.each do |entry|
741
+ @clock = Time.iso8601(entry[:at]) if entry[:at] && @clock
742
+ case entry[:step]
743
+ when :world then next
744
+ when :tick then tick(entry[:args])
745
+ when :offset then offset_step(entry[:args][:action], entry[:args][:args], entry[:args][:offset])
746
+ when :twins
747
+ a = entry[:args]
748
+ twins_step(a[:first], a[:first_args], a[:second], a[:second_args], a[:table])
749
+ when :interleave
750
+ a = entry[:args]
751
+ interleave(a[:first], a[:first_args], a[:second], a[:second_args])
752
+ else step(entry[:step], entry[:args], failure: entry[:failure])
753
+ end
754
+ end
755
+ end
756
+ end
757
+
758
+ module Report
759
+ module_function
760
+
761
+ def narrative(log)
762
+ lines = log.each_with_index.map do |e, i|
763
+ " #{i + 1}. [#{e[:at]}] #{e[:step]} #{e[:label]}#{" => #{e[:outcome]}" if e[:outcome] && e[:outcome] != "ok"}"
764
+ end
765
+ "Shortest sequence found:\n#{lines.join("\n")}"
766
+ end
767
+
768
+ def pasteable(definition, log)
769
+ steps = log.map { |e| " #{e.slice(:step, :args, :at, :failure).inspect}," }
770
+ <<~RUBY
771
+ Paste into the target's sequence spec to keep this case:
772
+
773
+ it "keeps the case #{definition.name} found" do
774
+ Sequences.replay(Sequences[#{definition.name.inspect}], [
775
+ #{steps.join("\n")}
776
+ ])
777
+ end
778
+ RUBY
779
+ end
780
+ end
781
+
782
+ def self.machine_for(definition, runner)
783
+ Class.new(Hegel::StateMachine) do
784
+ definition.actions.each_value do |action|
785
+ rule(action.name, weight: action.weight) do |tc|
786
+ runner.step(action.name, runner.choose(action, Draw.new(tc)))
787
+ end
788
+ end
789
+ definition.actions.each_value do |action|
790
+ action.failures.select { definition.failures.key?(it) }.each do |failure|
791
+ rule(:"#{action.name} [#{failure}]", weight: action.weight) do |tc|
792
+ runner.step(action.name, runner.choose(action, Draw.new(tc)), failure:)
793
+ end
794
+ end
795
+ end
796
+ definition.actions.each_value do |action|
797
+ next unless action.params.value?(:local_time)
798
+
799
+ rule(:"#{action.name} [other offset]", weight: action.weight) do |tc|
800
+ draw = Draw.new(tc)
801
+ runner.offset_step(action.name, runner.choose(action, draw), OFFSETS.fetch(draw.index(OFFSETS.size)))
802
+ end
803
+ end
804
+ definition.twin_pairs.each do |first_name, second_name, table|
805
+ first = definition.actions[first_name] or next
806
+ second = definition.actions[second_name] or next
807
+ rule(:"#{first_name} = #{second_name}", weight: 1.0) do |tc|
808
+ draw = Draw.new(tc)
809
+ first_choice = runner.choose(first, draw)
810
+ runner.twins_step(first_name, first_choice, second_name, runner.same_inputs(first, first_choice, second, draw), table)
811
+ end
812
+ end
813
+ # The same form sent twice (a double click, a retried request): one
814
+ # guest signed in twice on one day met a unique index only this way.
815
+ rule(:again, weight: 1.0) do |tc|
816
+ draw = Draw.new(tc)
817
+ done = runner.accepted_steps
818
+ draw.assume(!done.empty?)
819
+ sent = done.fetch(draw.index(done.size))
820
+ runner.step(sent[:step], sent[:args], failure: sent[:failure], again: true)
821
+ end
822
+ if definition.clock_start
823
+ rule(:tick, weight: definition.tick_weight) do |tc|
824
+ draw = Draw.new(tc)
825
+ stored = draw.int(0..1).zero? ? nil : draw.int(1..1000)
826
+ runner.tick(zone: draw.int(0..ZONES_FOR_DAY_EDGES.size - 1), days: draw.int(0..40),
827
+ time: draw.int(0..BOUNDARY_TIMES.size - 1), stored:)
828
+ end
829
+ end
830
+ unless definition.pausable.empty?
831
+ rule(:interleave, weight: 2.0) do |tc|
832
+ draw = Draw.new(tc)
833
+ # Hold first an action seen reaching an outside call, and run second
834
+ # an action on the same record, so the race has something to race on.
835
+ seen = definition.pausable.select { Sequences.outside_callers.include?(it.name) }
836
+ firsts = (seen + definition.pausable.select { it.pause_at == :batch_load }).uniq
837
+ firsts = definition.pausable if firsts.empty?
838
+ first = firsts.fetch(draw.index(firsts.size))
839
+ shared = definition.actions.values.select { (it.params.values & first.params.values).any? { |p| p.is_a?(Symbol) } }
840
+ # A job held after its batch load races an action on one of the
841
+ # rows it loaded, so the second is drawn from the record actions.
842
+ shared = definition.actions.values.select { |a| a.params.values.any? { it.is_a?(Symbol) && definition.pools.key?(it) } } if first.pause_at == :batch_load
843
+ seconds = draw.int(0..3).zero? || shared.empty? ? definition.actions.values : shared
844
+ second = seconds.fetch(draw.index(seconds.size))
845
+ first_choice = runner.choose(first, draw)
846
+ second_choice = runner.choose(second, draw)
847
+ second.params.each do |key, pool|
848
+ first_key = first.params.key(pool)
849
+ second_choice[key] = first_choice[first_key] if first_key && pool.is_a?(Symbol) && draw.int(0..3).positive?
850
+ end
851
+ runner.interleave(first.name, first_choice, second.name, second_choice)
852
+ end
853
+ end
854
+ end
855
+ end
856
+
857
+ # Blocks every definition runs first: the invariants and refusals that
858
+ # hold in every area, written once in spec/sequences/invariants.rb.
859
+ COLLISION = /ExclusionViolation|UniqueViolation|RecordNotUnique|conflicting key value|duplicate key/
860
+ TURNED_AWAY = /HTTP 40[13]|sign in|log in|signed in|unauthori[sz]ed|forbidden/i
861
+ def self.turned_away?(outcome) = outcome.to_s.match?(TURNED_AWAY)
862
+
863
+ def self.collision?(error) = error.message.match?(COLLISION)
864
+
865
+ def self.shared = (@shared ||= [])
866
+ def self.everywhere(&block) = shared << block
867
+
868
+ # The RSpec entry point: generate sequences, shrink a failure, and fail the
869
+ # example with the shortest sequence as a pasteable spec. SEQ_SEEDS=1,2,3
870
+ # runs one seed after another and stops at the first failure.
871
+ def self.explore(definition, **opts)
872
+ ENV["SEQ_OBSERVE"] ? observe(definition, out: ENV["SEQ_OBSERVE"]) : explore_cases(definition, **opts)
873
+ end
874
+
875
+ def self.explore_cases(definition, test_cases: 100, step_count: 12, seed: nil)
876
+ @first_failure = nil
877
+
878
+ Rails.application.eager_load!
879
+ quiet_errors
880
+ seeds = ENV["SEQ_SEEDS"]&.split(",")&.map { Integer(it) } || [seed || Random.new_seed % 2**31]
881
+ report = { area: definition.name, cases: test_cases, seeds: [], skipped: definition.skipped, actions: checks(definition),
882
+ params: definition.actions.transform_values { it.params.keys } }
883
+ # The world is built once before any case, so a broken setup stops here,
884
+ # unshrunk, naming its line.
885
+ begin
886
+ Runner.new(definition).then { |runner| with_zone("UTC") { runner.reset! } }
887
+ rescue SetupError => e
888
+ raise unless collision?(e)
889
+ end
890
+ seeds.each do |current|
891
+ report[:seeds] << explore_seed(definition, test_cases, step_count, current)
892
+ end
893
+ report[:verdict] = "clean"
894
+ rescue SetupError => e
895
+ report[:verdict] = "unchecked"
896
+ report[:reason] = e.message
897
+ raise
898
+ rescue ShrinkCut
899
+ # Shrinking had its share of the time: the shortest sequence found so
900
+ # far stands, and the run ends as a failure like any other.
901
+ report[:verdict] = "failure"
902
+ report[:reason] = @first_failure.message.strip.sub(/\A(.*)$/) { "#{$1} (shrinking stopped after #{SHRINK_SECONDS} s)" }
903
+ raise Failure.new(report[:reason].lines.first.chomp, log: @first_failure.log, definition:)
904
+ rescue SignalException => e
905
+ raise unless @first_failure
906
+
907
+ # The time limit cut shrinking: the shortest failure so far still
908
+ # stands, with its own sequence.
909
+ report[:verdict] = "failure"
910
+ report[:reason] = @first_failure.message.strip.sub(/\A(.*)$/) { "#{$1} (not shrunk: the time limit cut shrinking)" }
911
+ $stderr.puts "Sequences::Failure:\n#{report[:reason]}"
912
+ raise
913
+ rescue Exception => e # rubocop:disable Lint/RescueException
914
+ report[:verdict] = "failure"
915
+ report[:reason] = e.message.strip
916
+ raise
917
+ ensure
918
+ tallies = @tallies&.transform_values(&:dup) || {}
919
+ $stderr.puts coverage_report
920
+ report[:reached] = tallies.transform_keys(&:to_s)
921
+ report[:refused_with] = (@examples || {}).transform_values { |seen| seen.sort_by { -it[1] }.map(&:first) }
922
+ @examples = nil
923
+ # A refusal quotes a response body, which Rack hands over as binary; a
924
+ # byte that is not UTF-8 must not lose the whole report.
925
+ utf8 = ->(v) { v.is_a?(String) ? v.dup.force_encoding(Encoding::UTF_8).scrub : v.is_a?(Hash) ? v.to_h { [utf8.(_1), utf8.(_2)] } : v.is_a?(Array) ? v.map(&utf8) : v }
926
+ File.write(ENV["SEQ_REPORT"], JSON.pretty_generate(utf8.(report))) if ENV["SEQ_REPORT"]
927
+ reset_database
928
+ end
929
+
930
+ # A behavior diff from inputs, not from changed methods: every ordering of
931
+ # the area's actions up to SEQ_OBSERVE_LENGTH (3) steps, as many as
932
+ # SEQ_OBSERVE_CAP (240) allows, each from the same world with arguments
933
+ # drawn from a seed of its own. Each step writes one line: how it ended,
934
+ # the rows it added, changed or removed (by attributes, no ids or times),
935
+ # and the outside fakes whose state it changed. Run on base and on head,
936
+ # the two files differ exactly where behavior does; nothing here judges.
937
+ OBSERVE_NOISE = /\A(created_at|updated_at)\z|(\A|_)(token|digest|uuid|nonce|secret|salt)\z/
938
+
939
+ def self.observe(definition, out:)
940
+ Rails.application.eager_load!
941
+ quiet_errors
942
+ length = Integer(ENV.fetch("SEQ_OBSERVE_LENGTH", "3"))
943
+ cap = Integer(ENV.fetch("SEQ_OBSERVE_CAP", "240"))
944
+ names = definition.actions.keys.sort
945
+ orders = (1..length).flat_map { |n| names.repeated_permutation(n).to_a }
946
+ if orders.size > cap
947
+ short = orders.select { it.size < length }
948
+ orders = short.first(cap) + orders.select { it.size == length }.sample([cap - short.size, 0].max, random: Random.new(7))
949
+ end
950
+ File.open(out, "w") do |io|
951
+ orders.each_with_index do |order, i|
952
+ random = Random.new(1000 + i)
953
+ runner = Runner.new(definition)
954
+ begin
955
+ with_zone("UTC") { runner.reset! }
956
+ rescue SetupError => e
957
+ io.puts JSON.generate({ area: definition.name, seq: order, index: i, step: 0, action: "(setup)", outcome: "setup failed: #{e.message.lines.first.strip}" })
958
+ next
959
+ end
960
+ order.each_with_index do |name, k|
961
+ action = definition.actions.fetch(name)
962
+ choice = catch(:sequences_skip) { runner.choose(action, RandomDraw.new(random)) }
963
+ line = { area: definition.name, seq: order, index: i, step: k + 1, action: name }
964
+ if choice.nil?
965
+ io.puts JSON.generate(line.merge(outcome: "skipped: a pool was empty"))
966
+ next
967
+ end
968
+ before = runner.snapshot
969
+ fakes = outside_digest
970
+ line[:args] = (runner.describe(action, choice) rescue "?")
971
+ line[:outcome] = begin
972
+ with_zone("UTC") { runner.step(name, choice) && runner.log.last[:outcome] }
973
+ runner.log.last[:outcome].to_s
974
+ rescue Failure => e
975
+ "failed: #{e.message.lines.first.strip}"
976
+ rescue Diverged, StandardError => e
977
+ "raised #{e.class}: #{e.message.lines.first.to_s.strip}"
978
+ end
979
+ line[:writes] = row_changes(before, runner.snapshot)
980
+ after = outside_digest
981
+ line[:outside] = after.select { |id, (_, state)| fakes.key?(id) && fakes[id][1] != state }.map { it[1][0] }.uniq.sort
982
+ io.puts JSON.generate(line)
983
+ end
984
+ end
985
+ end
986
+ ensure
987
+ reset_database
988
+ end
989
+
990
+ # object id => [class name, state]. Keyed by object, not by class: fakes
991
+ # of earlier cases stay in ObjectSpace until a GC, so a class key picked
992
+ # an instance by heap order and the same step differed between two runs.
993
+ def self.outside_digest
994
+ outside_classes_declared.flat_map { |klass| ObjectSpace.each_object(klass).to_a }.to_h do |fake|
995
+ [fake.object_id, [fake.class.name, fake.instance_variables.to_h { [it, fake.instance_variable_get(it).inspect] }.inspect]]
996
+ end
997
+ end
998
+
999
+ # table => {"added" => [...], "changed" => [...], "removed" => [...]}, rows
1000
+ # by their non-volatile columns; ids appear only as the row they name.
1001
+ def self.row_changes(before, after)
1002
+ clean = ->(row) { row.reject { |c, _| OBSERVE_NOISE.match?(c) } }
1003
+ after.each_with_object({}) do |(table, rows), out|
1004
+ old = before.fetch(table, []).to_h { [it["id"], clean.(it)] }
1005
+ now = rows.to_h { [it["id"], clean.(it)] }
1006
+ added = now.reject { |id, _| old.key?(id) }.values
1007
+ removed = old.reject { |id, _| now.key?(id) }.values
1008
+ changed = now.filter_map do |id, row|
1009
+ next unless old.key?(id) && old[id] != row
1010
+
1011
+ { "id" => id }.merge(row.select { |c, v| old[id][c] != v }.to_h { |c, v| [c, [old[id][c], v]] })
1012
+ end
1013
+ next if added.empty? && removed.empty? && changed.empty?
1014
+
1015
+ out[table] = { "added" => added, "changed" => changed, "removed" => removed }.reject { _2.empty? }
1016
+ end
1017
+ end
1018
+
1019
+ def self.explore_seed(definition, test_cases, step_count, seed)
1020
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1021
+ count = 0
1022
+ first_failure = nil
1023
+ noise = Set.new
1024
+ shrink = !ENV["SEQ_NO_SHRINK"]
1025
+ Hegel.test(test_cases:, seed:, verbosity: :quiet,
1026
+ phases: shrink ? nil : %i[generate],
1027
+ suppress_health_check: %i[too_slow filter_too_much]) do |tc|
1028
+ count += 1
1029
+ raise ShrinkCut if first_failure && Process.clock_gettime(Process::CLOCK_MONOTONIC) - started - first_failure[1] > SHRINK_SECONDS
1030
+
1031
+ begin
1032
+ run_case(definition, tc, step_count, noise)
1033
+ rescue Failure => e
1034
+ first_failure ||= [count, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started]
1035
+ # Keep the shortest sequence of the same failure seen while shrinking.
1036
+ @first_failure = e if @first_failure.nil? ||
1037
+ (e.log.size < @first_failure.log.size && e.to_s.lines.first == @first_failure.to_s.lines.first)
1038
+ raise
1039
+ end
1040
+ end
1041
+ { seed:, runs: count, seconds: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(1) }
1042
+ ensure
1043
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
1044
+ $stderr.puts "sequences #{definition.name} seed #{seed}: #{count} runs in #{elapsed.round(1)} s"
1045
+ if first_failure
1046
+ $stderr.puts "first failure at run #{first_failure[0]} after #{first_failure[1].round(1)} s" +
1047
+ (shrink ? "; the rest shrank it" : "; not shrunk (SEQ_NO_SHRINK)")
1048
+ end
1049
+ end
1050
+
1051
+ def self.run_case(definition, tc, step_count, noise)
1052
+ runner = Runner.new(definition)
1053
+ begin
1054
+ with_zone("UTC") { runner.reset!(draw: Draw.new(tc)) }
1055
+ rescue SetupError => e
1056
+ raise unless collision?(e)
1057
+
1058
+ # Two drafted records met a unique index or an exclusion constraint:
1059
+ # the world's draw, not the app. The case is drawn again.
1060
+ tally(:setup, "collided, drawn again")
1061
+ tc.assume(false)
1062
+ end
1063
+ begin
1064
+ with_zone("UTC") { Hegel::Stateful.run(machine_for(definition, runner).new, tc, step_count:) }
1065
+ rescue Exception => e
1066
+ $stderr.puts " case ended: #{e.class}: #{e.message[0, 200]} #{e.backtrace.first(6).join(" | ")}" if ENV["SEQ_DEBUG"]
1067
+ raise
1068
+ end
1069
+ return if definition.zones.empty?
1070
+
1071
+ base = runner.snapshot
1072
+ zone = definition.zones.fetch(tc.draw(Hegel::Generators::IntegerGenerator.new(min_value: 0, max_value: definition.zones.size - 1)))
1073
+ # Under another zone an earlier step can end differently, so a later step
1074
+ # finds no record to act on. That is the zone changing the result.
1075
+ replayed = begin
1076
+ replay_snapshot(definition, runner.log, zone)
1077
+ rescue Diverged => e
1078
+ raise Failure.new("process time zone changed the result: TZ=UTC vs TZ=#{zone}\n#{e.message}", log: runner.log, definition:)
1079
+ end
1080
+ diff = diff(base, replayed, noise)
1081
+ return if diff.empty?
1082
+
1083
+ # A column that differs between two UTC runs of the same steps (a random
1084
+ # token, a fake's charge id) says nothing about time zones, and neither
1085
+ # does one that differs between two runs in the other zone: a `has_one`
1086
+ # over several rows picks whichever row PostgreSQL returns first.
1087
+ 2.times { diff(base, replay_snapshot(definition, runner.log, "UTC"), Set.new).each { noise << it[:column] } }
1088
+ begin
1089
+ diff(replayed, replay_snapshot(definition, runner.log, zone), Set.new).each { noise << it[:column] }
1090
+ rescue Diverged
1091
+ return
1092
+ end
1093
+ diff = diff.reject { noise.include?(it[:column]) }
1094
+ return if diff.empty?
1095
+
1096
+ raise Failure.new("process time zone changed the result: TZ=UTC vs TZ=#{zone}\n#{diff.first(6).map { it[:text] }.join("\n")}",
1097
+ log: runner.log, definition: definition)
1098
+ end
1099
+
1100
+ # What a clean verdict has to have reached: every action, its offset
1101
+ # replay, and each pair of doors.
1102
+ def self.checks(definition)
1103
+ definition.actions.keys +
1104
+ definition.actions.values.select { it.params.value?(:local_time) }.map { :"#{it.name} [other offset]" } +
1105
+ definition.twin_pairs.select { |a, b, _| definition.actions.key?(a) && definition.actions.key?(b) }.map { |a, b, _| :"#{a} = #{b}" }
1106
+ end
1107
+
1108
+ # For a drafted invariant: see Runner#order_kept.
1109
+ def self.order_kept(model, **options) = Thread.current[:sequences_runner].order_kept(model, **options)
1110
+
1111
+ def self.reached_outside_call(action, klass)
1112
+ return unless action
1113
+
1114
+ outside_callers << action
1115
+ (@outside_classes ||= Hash.new { |h, k| h[k] = Set.new })[action] << klass
1116
+ end
1117
+
1118
+ def self.outside_callers = (@outside_callers ||= Set.new)
1119
+ def self.outside_classes = (@outside_classes ||= Hash.new { |h, k| h[k] = Set.new })
1120
+
1121
+ # What the run reached, so a clean result says what it covered: an action
1122
+ # that was only ever refused, or never drawn, checked nothing.
1123
+ def self.tally(name, outcome)
1124
+ kind = kind(outcome)
1125
+ (@tallies ||= Hash.new { |h, k| h[k] = Hash.new(0) })[name][kind] += 1
1126
+ return unless kind.include?("refused")
1127
+
1128
+ # Up to three distinct refusals, counted: the first one seen can be the
1129
+ # rare one (an invalid kind) and hide the one every other draw met.
1130
+ seen = ((@examples ||= {})[name.to_s] ||= Hash.new(0))
1131
+ text = outcome.to_s[0, 160]
1132
+ seen[text] += 1 if seen.key?(text) || seen.size < 3
1133
+ end
1134
+
1135
+ def self.kind(outcome)
1136
+ kind = outcome.to_s[/\A(refused: [\w:]+|HTTP \d\d\d refused|HTTP \d|ok|paused, second waited on a lock|paused|no outside call)/] || outcome.to_s[0, 30]
1137
+ kind.sub(/HTTP (\d)(\d\d)?/, 'HTTP \1xx')
1138
+ end
1139
+
1140
+ # Plain random draws for the probe: no shrinking, no Hegel.
1141
+ class RandomDraw
1142
+ def initialize(random) = (@random = random)
1143
+ def index(size) = @random.rand(size)
1144
+ def int(range) = @random.rand(range)
1145
+ def assume(ok) = ok || throw(:sequences_skip)
1146
+ end
1147
+
1148
+ # A random walk over every action with no invariants: the tables each
1149
+ # action writes, the outside services it calls, and how it ends. propose.rb
1150
+ # groups actions into areas by it.
1151
+ def self.probe(definition, walks: 60, steps: 20, seed: 1)
1152
+ Rails.application.eager_load!
1153
+ quiet_errors
1154
+ random = Random.new(seed)
1155
+ stats = Hash.new { |h, k| h[k] = { writes: Hash.new(0), reads: Hash.new(0), outcomes: Hash.new(0), errors: Hash.new(0), refusals: [], runs: 0 } }
1156
+ sub = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
1157
+ name = Thread.current[:sequences_action] or next
1158
+ verb, table = payload[:sql].match(/\A\s*(INSERT INTO|UPDATE|DELETE FROM)\s+"?(\w+)"?/i)&.captures
1159
+ stats[name][:writes]["#{verb.split.first.downcase} #{table}"] += 1 if table
1160
+ payload[:sql].scan(/\b(?:FROM|JOIN)\s+"?(\w+)"?/i).flatten.each { stats[name][:reads][it] += 1 } if payload[:sql].match?(/\A\s*SELECT/i)
1161
+ end
1162
+ actions = definition.actions.values
1163
+ walks.times do
1164
+ runner = Runner.new(definition)
1165
+ # A world whose drafted records collide is drawn again, as a case is.
1166
+ begin
1167
+ with_zone("UTC") { runner.reset!(draw: RandomDraw.new(random)) }
1168
+ rescue SetupError => e
1169
+ raise unless collision?(e)
1170
+
1171
+ next
1172
+ end
1173
+ steps.times do
1174
+ if definition.clock_start && random.rand(5).zero?
1175
+ runner.tick(zone: random.rand(ZONES_FOR_DAY_EDGES.size), days: random.rand(0..20), time: random.rand(BOUNDARY_TIMES.size), stored: nil)
1176
+ next
1177
+ end
1178
+ # The first action, in random order, whose pools hold a record.
1179
+ action, choice = nil
1180
+ actions.shuffle(random:).each do |candidate|
1181
+ drawn = catch(:sequences_skip) { runner.choose(candidate, RandomDraw.new(random)) }
1182
+ next unless drawn
1183
+
1184
+ action, choice = candidate, drawn
1185
+ break
1186
+ end
1187
+ next unless action
1188
+
1189
+ begin
1190
+ stats[action.name][:runs] += 1
1191
+ with_zone("UTC") { runner.step(action.name, choice) }
1192
+ outcome = runner.log.last[:outcome]
1193
+ stats[action.name][:outcomes][kind(outcome)] += 1
1194
+ refusals = stats[action.name][:refusals]
1195
+ refusals << outcome.to_s[0, 200] if kind(outcome).include?("refused") && refusals.size < 3 && !refusals.include?(outcome.to_s[0, 200])
1196
+ rescue Failure => e
1197
+ stats[action.name][:errors][e.cause ? e.cause.class.name : e.message.lines.first.strip[0, 80]] += 1
1198
+ end
1199
+ end
1200
+ end
1201
+ stats.each { |name, st| st[:outside] = outside_classes[name].map(&:name).sort }
1202
+ actions.each { stats[it.name] }
1203
+ stats
1204
+ ensure
1205
+ ActiveSupport::Notifications.unsubscribe(sub) if sub
1206
+ @tallies = nil
1207
+ reset_database
1208
+ end
1209
+
1210
+ def self.coverage_report
1211
+ return "" unless @tallies
1212
+
1213
+ lines = @tallies.sort_by { |k, _| k.to_s }.map { |name, kinds| " #{name}: #{kinds.map { |k, n| "#{k} #{n}" }.join(", ")}" }
1214
+ @tallies = nil
1215
+ return "" if lines.empty?
1216
+ "steps reached (outcome count):\n#{lines.join("\n")}"
1217
+ end
1218
+
1219
+ def self.world_sizes(log) = log.find { it[:step] == :world }&.dig(:args) || []
1220
+
1221
+ # The fakes are plain objects that remember charges and failures armed; a
1222
+ # trial's rollback must undo those too.
1223
+ def self.save_outside_state
1224
+ seqs = ActiveRecord::Base.connection.select_rows(sequence_state_sql)
1225
+ fakes = outside_classes_declared.flat_map { |klass| ObjectSpace.each_object(klass).to_a }.map do |fake|
1226
+ [fake, fake.instance_variables.to_h { [it, Marshal.load(Marshal.dump(fake.instance_variable_get(it)))] }]
1227
+ rescue TypeError
1228
+ nil
1229
+ end
1230
+ [seqs, fakes.compact]
1231
+ end
1232
+
1233
+ def self.restore_outside_state((seqs, fakes))
1234
+ conn = ActiveRecord::Base.connection
1235
+ conn.execute(seqs.map { |name, value, called| "SELECT setval(#{conn.quote(name)}, #{Integer(value)}, #{called})" }.join("; ")) unless seqs.empty?
1236
+ fakes.each { |fake, ivars| ivars.each { fake.instance_variable_set(_1, _2) } }
1237
+ end
1238
+
1239
+ def self.sequence_state_sql
1240
+ @sequence_state_sql ||= begin
1241
+ conn = ActiveRecord::Base.connection
1242
+ names = conn.select_values("SELECT sequencename FROM pg_sequences WHERE schemaname = current_schema()")
1243
+ names.map { "SELECT #{conn.quote(it)}, last_value, is_called FROM #{conn.quote_table_name(it)}" }.join(" UNION ALL ")
1244
+ end
1245
+ end
1246
+
1247
+ def self.outside_classes_declared = (@outside_classes_declared ||= Set.new)
1248
+
1249
+ def self.replay(definition, entries)
1250
+ Rails.application.eager_load!
1251
+ runner = Runner.new(definition)
1252
+ runner.reset!(sizes: world_sizes(entries))
1253
+ runner.replay(entries)
1254
+ ensure
1255
+ reset_database
1256
+ end
1257
+
1258
+ def self.reset_database = empty_tables
1259
+
1260
+ # A 404 or 422 renders the plain error page, not the developer page:
1261
+ # building that page (source extracts, error highlight) took a third of
1262
+ # the run. The status the action answers is unchanged.
1263
+ def self.quiet_errors = Rails.application.env_config["action_dispatch.show_detailed_exceptions"] = false
1264
+
1265
+ # Empties every table and restarts its ids, so two runs of the same steps
1266
+ # get the same ids. DELETE in foreign-key order (children first) took 1 ms
1267
+ # on this app's few rows where TRUNCATE ... RESTART IDENTITY took 72 ms.
1268
+ # A schema whose keys form a cycle falls back to TRUNCATE.
1269
+ def self.empty_tables
1270
+ conn = ActiveRecord::Base.connection
1271
+ @empty_sql ||= begin
1272
+ tables = conn.tables - %w[schema_migrations ar_internal_metadata]
1273
+ parents = tables.to_h { |t| [t, conn.foreign_keys(t).map(&:to_table) - [t]] }
1274
+ order = []
1275
+ left = tables.dup
1276
+ until left.empty?
1277
+ leaves = left.reject { |t| left.any? { |other| parents[other].include?(t) } }
1278
+ break if leaves.empty?
1279
+
1280
+ order.concat(leaves)
1281
+ left -= leaves
1282
+ end
1283
+ seqs = conn.select_rows("SELECT pg_get_serial_sequence(quote_ident(t), 'id') FROM unnest(ARRAY[#{tables.map { conn.quote(it) }.join(",")}]::text[]) t").flatten.compact
1284
+ if left.empty?
1285
+ (order.map { "DELETE FROM #{conn.quote_table_name(it)}" } + seqs.map { "SELECT setval(#{conn.quote(it)}, 1, false)" }).join("; ")
1286
+ else
1287
+ "TRUNCATE #{tables.map { conn.quote_table_name(it) }.join(", ")} RESTART IDENTITY CASCADE"
1288
+ end
1289
+ end
1290
+ conn.execute(@empty_sql)
1291
+ end
1292
+
1293
+
1294
+ def self.with_zone(zone)
1295
+ old = ENV["TZ"]
1296
+ ENV["TZ"] = zone
1297
+ yield
1298
+ ensure
1299
+ ENV["TZ"] = old
1300
+ end
1301
+
1302
+ def self.replay_snapshot(definition, log, zone)
1303
+ runner = Runner.new(definition)
1304
+ with_zone(zone) do
1305
+ runner.reset!(sizes: world_sizes(log))
1306
+ runner.replay(log)
1307
+ runner.snapshot
1308
+ end
1309
+ end
1310
+
1311
+ def self.diff(a, b, noise)
1312
+ a.flat_map do |table, rows|
1313
+ other = b.fetch(table)
1314
+ next [{ column: table, text: "#{table}: #{rows.size} rows vs #{other.size} rows" }] if rows.size != other.size
1315
+
1316
+ rows.zip(other).flat_map do |x, y|
1317
+ x.filter_map do |col, v|
1318
+ next if v == y[col] || noise.include?("#{table}.#{col}")
1319
+
1320
+ { column: "#{table}.#{col}", text: "#{table}##{x["id"]}.#{col}: #{v.inspect} vs #{y[col].inspect}" }
1321
+ end
1322
+ end
1323
+ end
1324
+ end
1325
+ end