diff --git a/blog_giveaway_demo.rb b/blog_giveaway_demo.rb new file mode 100644 index 0000000..eaa58d5 --- /dev/null +++ b/blog_giveaway_demo.rb @@ -0,0 +1,153 @@ +# Proves Post#with_lock (Prism::Orm::Pessimistic, see ClaimsController#create) +# actually prevents overselling a limited giveaway under real concurrency - +# not threads inside one process, but several genuinely independent +# `Prism::Server` OS processes (see blog_giveaway_worker.rb) all pointed at +# the SAME shared database file, racing to POST /posts/:slug/claims on the +# same post at (as close to) the same instant as a spin-wait barrier on a +# shared "go" file can arrange. +# +# A post with giveaway_slots: 3 and 8 distinct entrants means at most 3 can +# ever legitimately win a slot - if the read-count-then-decide in +# ClaimsController#create were not held under one lock for its entire +# critical section, several of those 8 requests could all read the same +# "2 of 3 claimed" snapshot and all decide a slot was still free, oversold. +require 'fileutils' +require 'rbconfig' +require 'net/http' +require 'tmpdir' + +ROOT = __dir__ +SCRATCH_DB_DIR = File.join(ROOT, 'examples/blog/db/giveaway_demo') +RUBY = RbConfig.ruby +GIVEAWAY_SLOTS = 3 +# Capped by examples/blog/app.rb's own 'logins/ip' throttle +# (limit: 5, period: 60) - every entrant below signs in over a real POST +# /session from this same machine's loopback address, exactly like every +# other real visitor would, so this demo has to live within the same +# limit a real attacker would be stopped by rather than special-casing +# itself around it. 5 entrants racing 3 slots is still more than enough +# to prove an oversell is impossible (2 must lose). +ENTRANTS = 5 + +# Fresh scratch database every run, isolated from examples/blog/db/ +# development.pstore (blog_demo.rb's own data). +FileUtils.rm_rf(SCRATCH_DB_DIR) +ENV['PRISM_BLOG_DB_DIR'] = SCRATCH_DB_DIR + +$LOAD_PATH.unshift(File.join(ROOT, 'lib')) +require 'prism' +require_relative 'examples/blog/app' + +def cookie_from(response) + (response.get_fields('set-cookie') || []).map { |line| line.split(';').first }.join('; ') +end + +def csrf_token_from(html) + html[/name="csrf-token" content="([^"]+)"/, 1] +end + +setup_server = Prism::Server.new(Blog, port: 0).start +slug = nil + +Net::HTTP.start('127.0.0.1', setup_server.port) do |http| + # The host: creates the giveaway post. Entrants never author anything, + # they only ever race to claim a slot on this one post. + new_session = http.get('/session/new') + cookie = cookie_from(new_session) + token = csrf_token_from(new_session.body) + signup_body = URI.encode_www_form( + 'user[email]' => 'host@example.com', 'user[password]' => 'secret123', + 'user[password_confirmation]' => 'secret123', 'authenticity_token' => token + ) + signup = http.post('/users', signup_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie) + raise "host signup failed (#{signup.code})" unless signup.code == '302' + cookie = cookie_from(signup) + + home = http.get('/', 'Cookie' => cookie) + token = csrf_token_from(home.body) + create_body = URI.encode_www_form( + 'post[title]' => 'Signed copy giveaway', 'post[body]' => 'Three signed copies, first come first served.', + 'post[giveaway_slots]' => GIVEAWAY_SLOTS.to_s, 'authenticity_token' => token + ) + created = http.post('/posts', create_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie) + raise "post creation failed (#{created.code})" unless created.code == '302' + slug = created['location'][%r{/posts/([^/?]+)}, 1] + raise 'could not extract slug from Location header' unless slug +end + +# ENTRANTS distinct accounts, created directly against the store rather +# than through POST /users - each entrant still authenticates over a real +# POST /session in blog_giveaway_worker.rb (that is the request that has +# to survive examples/blog's real login throttle), but their signups +# happening over HTTP too would trip the SEPARATE 'signups/ip' throttle +# (limit: 3) with only 1 (host) + ENTRANTS of them all coming from this +# same demo process' loopback address - a constraint about this demo +# script issuing many signups back to back, not about the giveaway locking +# this file exists to prove. +ENTRANTS.times do |i| + User.create!( + 'email' => "entrant#{i}@example.com", 'password' => 'secret123', 'password_confirmation' => 'secret123' + ) +end + +puts "setup done: post '#{slug}' has #{GIVEAWAY_SLOTS} giveaway slots, #{ENTRANTS} entrants about to race for them" + +Dir.mktmpdir('giveaway_demo') do |scratch| + go_file = File.join(scratch, 'go') + pids_and_outputs = ENTRANTS.times.map do |i| + label = "entrant#{i}" + out_path = File.join(scratch, "#{label}.out") + pid = Process.spawn( + { 'PRISM_BLOG_DB_DIR' => SCRATCH_DB_DIR }, RUBY, '-Ilib', 'blog_giveaway_worker.rb', + label, "#{label}@example.com", 'secret123', slug, go_file, + chdir: ROOT, out: out_path, err: File.join(scratch, "#{label}.err") + ) + [pid, out_path] + end + + # Generous: a cold `ruby` boot of a fresh Prism::Server (migrations + # re-checked, sign-in, a page load for a CSRF token) per worker, times + # ENTRANTS of them all starting around the same moment, comfortably + # finishes well under this - every worker should already be parked on + # its spin-wait by the time this file appears. + sleep 3 + File.write(go_file, 'go') + + results = pids_and_outputs.map do |pid, out_path| + _, status = Process.wait2(pid) + raise "worker exited with #{status}, see #{out_path}" unless status.success? + File.read(out_path).strip + end + + outcomes = results.each_with_object(Hash.new(0)) do |line, tally| + _label, outcome = line.split(':') + tally[outcome] += 1 + end + + puts "outcomes across #{ENTRANTS} racing, fully independent server processes: #{outcomes}" + raise "expected already_claimed count 0, got #{outcomes['already_claimed']}" unless outcomes['already_claimed'].zero? + raise "expected unknown count 0, got #{outcomes['unknown']}" unless outcomes['unknown'].zero? + unless outcomes['claimed'] == GIVEAWAY_SLOTS + raise "expected exactly #{GIVEAWAY_SLOTS} claimed slots, got #{outcomes['claimed']} - Post#with_lock did not " \ + 'prevent an oversell' + end + unless outcomes['sold_out'] == ENTRANTS - GIVEAWAY_SLOTS + raise "expected #{ENTRANTS - GIVEAWAY_SLOTS} sold_out responses, got #{outcomes['sold_out']}" + end + puts "exactly #{GIVEAWAY_SLOTS} of #{ENTRANTS} racing requests won a slot, the rest correctly saw sold_out - no oversell" +end + +# Independent confirmation straight from the store, not just trusting the +# workers' own HTTP responses: a brand new transaction against the same +# file, opened fresh by THIS process, after every worker process has +# already exited and released its own file lock. +post = Post.find(Post.find_by!(slug: slug).id) +claim_count = Claim.where(post_id: post.id).count +if claim_count != GIVEAWAY_SLOTS + raise "Claim.count on disk is #{claim_count}, expected exactly #{GIVEAWAY_SLOTS}" +end +if post.claims_count != GIVEAWAY_SLOTS + raise "Post#claims_count is #{post.claims_count}, expected exactly #{GIVEAWAY_SLOTS} (counter cache drifted from the real row count)" +end +puts "storage-level check: Claim.where(post_id: ...).count == #{claim_count}, Post#claims_count == #{post.claims_count}, both exactly #{GIVEAWAY_SLOTS}" +puts 'blog_giveaway_demo done: a hard-capped giveaway held its cap under real cross-process concurrency'