# Proves, over a real TCP socket (not just a direct Application.call like # test/test_application_n_plus_one.rb), what examples/blog/app.rb's # PRISM_BLOG_STRICT_N_PLUS_ONE=1 gate actually buys: before this session, # Orm::NPlusOneDetector.report! was called AFTER Application#dispatch's # instrumented span had already closed its rescue reach, so a violation blew # straight out of #dispatch, past every middleware, and past # Server#serve's `@app.call(env)` too (server.rb only rescues # Errno::EPIPE/Errno::ECONNRESET/IOError around that call, never plain # StandardError) - the request thread died with the socket just closed and # NOT ONE byte written back, i.e. a client seeing a broken connection, not an # HTTP response. #dispatch now rescues the violation in the same place as # any other bug and routes it through handle_uncaught_exception, so # raise_on_violation actually behaves like every other exception in this # framework: a normal 500 a curl/CI run can see and assert on. # # Uses a tiny synthetic app (not examples/blog, which is deliberately kept # N+1-free) with ONE route that has a real, deliberately-unfixed N+1 - # reintroducing a genuine bug is exactly what a regression gate needs to be # proven against; asserting against blog's own already-clean routes would # only prove "doesn't false-positive," not "catches a real regression." $LOAD_PATH.unshift(File.expand_path('lib', __dir__)) require 'prism' require 'net/http' require 'tmpdir' require 'stringio' dir = Dir.mktmpdir('prism-strict-n-plus-one-demo') store = Prism::Orm::Store.new(File.join(dir, 'demo.pstore')) store.create_table(:authors, %w[id name]) store.create_table(:posts, %w[id title author_id]) class DemoAuthor < Prism::Orm::Model table_name :authors has_many :posts, class_name: 'DemoPost', foreign_key: 'author_id' end class DemoPost < Prism::Orm::Model table_name :posts belongs_to :author, class_name: 'DemoAuthor', foreign_key: 'author_id' end [DemoAuthor, DemoPost].each { |klass| klass.store = store } 3.times do |i| author = DemoAuthor.create!(name: "author#{i}") DemoPost.create!(title: "post#{i}", author_id: author.id) end log = StringIO.new class DemoApp < Prism::Application # Genuinely N+1: no .includes(:posts), so each author's #posts is a lazy # cache-miss load - exactly the pattern examples/blog's PostsController # used to have on Comment#likes before it was found and fixed (see # claudocs/plan/prism/stan.md, N+1 detector session). get('/regression') do names = DemoAuthor.all.to_a.map { |author| "#{author.name}: #{author.posts.count} post(s)" } names.join(', ') end # Same query, fixed the way PostsController actually was: eager-loaded, so # no lazy load ever fires and the detector has nothing to count. get('/clean') do names = DemoAuthor.all.includes(:posts).to_a.map { |author| "#{author.name}: #{author.posts.size} post(s)" } names.join(', ') end end def get(port, path) Net::HTTP.start('127.0.0.1', port) { |http| http.get(path) } end puts '=' * 60 puts 'phase 1: raise_on_violation: true (PRISM_BLOG_STRICT_N_PLUS_ONE=1 equivalent)' Prism::Orm::NPlusOneDetector.enable!(threshold: 2, io: log, raise_on_violation: true) server = Prism::Server.new(DemoApp, port: 0).start port = server.port response = get(port, '/regression') puts "GET /regression -> #{response.code} #{response['Content-Type']} body=#{response.body.inspect}" raise "expected a real 500, got #{response.code}" unless response.code == '500' raise 'expected the normal uncaught-exception body, not something ad hoc' unless response.body == 'Internal Server Error' raise 'expected the violation to still have been logged to the detector io' unless log.string.include?('N+1 query detected: DemoAuthor#posts') puts 'confirmed: a real 500 response, not a broken connection - the violation was logged, exactly like any other bug' # The connection dying silently (the pre-fix behavior) would have made this # second request on a FRESH connection unremarkable either way - the real # proof is that the server thread handling /regression is still alive and # the process itself never crashed, which this second, unrelated request # after the violation demonstrates. follow_up = get(port, '/clean') puts "GET /clean (after the violation) -> #{follow_up.code} body=#{follow_up.body.inspect}" raise "expected the clean route to still work fine after a violation elsewhere, got #{follow_up.code}" unless follow_up.code == '200' raise 'expected includes(:posts) to prevent any lazy load / violation' if log.string.scan('N+1 query detected').size > 1 puts 'confirmed: eager-loaded route stays silent and the server keeps serving requests after a violation' Prism::Orm::NPlusOneDetector.disable! Prism::Orm::NPlusOneDetector.reset! puts '-' * 60 puts 'phase 2: log-only mode (raise_on_violation: false, the default when PRISM_BLOG_STRICT_N_PLUS_ONE is unset)' log2 = StringIO.new Prism::Orm::NPlusOneDetector.enable!(threshold: 2, io: log2, raise_on_violation: false) response2 = get(port, '/regression') puts "GET /regression -> #{response2.code} body=#{response2.body.inspect}" raise "expected a normal 200 in log-only mode, got #{response2.code}" unless response2.code == '200' raise 'expected the SAME regression to still be logged, just not raised' unless log2.string.include?('N+1 query detected: DemoAuthor#posts') puts 'confirmed: same regression, same log line, but the response is a plain 200 - nothing breaks for real traffic ' \ 'when the strict flag is off' Prism::Orm::NPlusOneDetector.disable! Prism::Orm::NPlusOneDetector.reset! puts '=' * 60 puts 'strict_n_plus_one_demo done: raise_on_violation now surfaces as a normal 500 over real HTTP (never a dead ' \ 'connection), and the same underlying regression is silent-but-logged when the strict flag is off.'