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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.ja.md +235 -0
- data/README.md +191 -0
- data/bin/holdfast +4 -0
- data/lib/holdfast/callers.rb +124 -0
- data/lib/holdfast/change.rb +59 -0
- data/lib/holdfast/check.rb +473 -0
- data/lib/holdfast/cli.rb +105 -0
- data/lib/holdfast/draft.rb +303 -0
- data/lib/holdfast/drawn.rb +186 -0
- data/lib/holdfast/harness.rb +90 -0
- data/lib/holdfast/inputs.rb +105 -0
- data/lib/holdfast/record.rb +132 -0
- data/lib/holdfast/runtime/check_support.rb +666 -0
- data/lib/holdfast/runtime/observe.rb +136 -0
- data/lib/holdfast/runtime/reach.rb +104 -0
- data/lib/holdfast/sequences/propose.rb +1506 -0
- data/lib/holdfast/sequences/run.rb +197 -0
- data/lib/holdfast/sequences/sequences.rb +1325 -0
- data/lib/holdfast/stages/diff.rb +213 -0
- data/lib/holdfast/stages/fill.rb +194 -0
- data/lib/holdfast/stages/fold.rb +256 -0
- data/lib/holdfast/stages/hunt.rb +176 -0
- data/lib/holdfast/stages/observe.rb +109 -0
- data/lib/holdfast/stages/select.rb +94 -0
- data/lib/holdfast/tools.rb +33 -0
- data/lib/holdfast/verdict.rb +69 -0
- data/lib/holdfast/version.rb +5 -0
- data/lib/holdfast.rb +44 -0
- data/skills/holdfast/SKILL.md +96 -0
- metadata +122 -0
|
@@ -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
|