# frozen_string_literal: true require "fiber" module Concurrency module Logic class DeadEnd < StandardError end module Complete class Path def initialize @path = [] @index = 0 end def to_s "#" end def choose(*options) raise DeadEnd if options.empty? @path << [options.size, 0] if @index >= @path.size pair = @path[@index] raise "non-determinism" unless pair[0] == options.size @index += 1 options[pair[1]] end def choose_with_weights(*options) choose(options.map(&:first)) end def dead_end raise DeadEnd end def guard(condition) dead_end unless condition end def next @index = 0 until @path.empty? pair = @path.last pair[1] += 1 if pair[1] < pair[0] break else @path.pop end end !@path.empty? end end def self.run(&blk) path = Path.new possibilities = [] while true begin possibilities << blk.call(path) rescue DeadEnd end break unless path.next end possibilities end end module Sampling class Path def initialize(random) @random = random end def to_s "#" end def choose(*options) options.sample(random: @random) end def choose_with_weights(*options) position = @random.rand options.each do |(option, weight)| if position <= weight return option else position -= weight end end raise "weights don't add up" end def dead_end raise DeadEnd end def guard(condition) dead_end unless condition end end def self.run(seed, runs, &blk) seed = seed.to_i possibilities = [] runs.times do |i| path = Path.new(Random.new(seed + i)) begin possibilities << blk.call(path) rescue DeadEnd end end possibilities end end def self.run(seed: nil, runs: nil, &blk) if runs.present? Sampling.run(seed, runs, &blk) else Complete.run(&blk) end end end class Scenario def initialize(&blk) @blk = blk end class Execution attr_reader :path def initialize(path) @path = path @tasks = [] end def yield Fiber.yield end def choose(*options) @path.choose(*options) end def choose_with_weights(*options) @path.choose_with_weights(*options) end def spawn(&blk) @tasks << Fiber.new(&blk) end def run until @tasks.empty? task = @path.choose(*@tasks) task.resume @tasks.delete(task) unless task.alive? end end end def run_with_path(path) execution = Execution.new(path) result = @blk.call(execution) execution.run result end def run(**opts) Logic.run(**opts, &method(:run_with_path)) end end class RedisWrapper def initialize(redis, execution) @redis = redis @execution = execution @in_transaction = false end def multi(&blk) with_possible_failure { with_in_transaction { @redis.multi(&blk) } } end def method_missing(method, *args, &blk) if @in_transaction @redis.send(method, *args, &blk) else with_possible_failure { @redis.send(method, *args, &blk) } end end private def with_in_transaction previous_value, @in_transaction = @in_transaction, true begin yield ensure @in_transaction = previous_value end end def with_possible_failure outcome = @execution.choose_with_weights([:succeed, 0.96], [:fail_before, 0.02], [:fail_after, 0.02]) @execution.yield raise Redis::ConnectionError if outcome == :fail_before result = yield @execution.yield raise Redis::ConnectionError if outcome == :fail_after result end end end