diff --git a/.app-image-tag b/.app-image-tag index 24cc4944..9e5b81c1 100644 --- a/.app-image-tag +++ b/.app-image-tag @@ -1 +1 @@ -v0.10.1.13 +v0.10.1.15 diff --git a/Gemfile b/Gemfile index 82c46598..504186d5 100644 --- a/Gemfile +++ b/Gemfile @@ -21,7 +21,7 @@ gem "pry", "~> 0.16.0" # for documentation server gem "puma" -gem "rack", "~> 2.2.21" +gem "rack", "~> 2.2.22" gem "yard" group :test do diff --git a/Gemfile.lock b/Gemfile.lock index 58871975..322460c0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -73,7 +73,7 @@ GEM puma (7.2.0) nio4r (~> 2.0) racc (1.8.1) - rack (2.2.21) + rack (2.2.22) rainbow (3.1.1) rake (13.3.1) readline (0.0.4) @@ -98,7 +98,7 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-support (3.13.6) - rubocop (1.84.1) + rubocop (1.84.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -150,7 +150,7 @@ DEPENDENCIES pg (~> 1.6) pry (~> 0.16.0) puma - rack (~> 2.2.21) + rack (~> 2.2.22) rake (~> 13.3) readline redis (~> 5.4.1) diff --git a/README.md b/README.md index 082c8675..704ccf87 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,11 @@ ActiveRecordProxyAdapters.configure do |config| # How long proxy should wait for replica to connect. config.checkout_timeout = 5.seconds # defaults to 2.seconds + + # Whether to persist write timestamps in a cookie for cross-request stickiness. + # When false, the middleware still creates a fresh context per request (preventing + # thread-local leaks in multi-threaded servers) but skips reading/writing the cookie. + config.stickiness_cookie_enabled = false # defaults to true end ``` diff --git a/gemfiles/ar_7.2.gemfile b/gemfiles/ar_7.2.gemfile index 702ecbc2..b1f7308b 100644 --- a/gemfiles/ar_7.2.gemfile +++ b/gemfiles/ar_7.2.gemfile @@ -15,7 +15,7 @@ gem "logger" gem "readline" gem "pry", "~> 0.16.0" gem "puma" -gem "rack", "~> 2.2.21" +gem "rack", "~> 2.2.22" gem "yard" gem "activerecord", "~> 7.2.0" gem "activesupport", "~> 7.2.0" diff --git a/gemfiles/ar_8.0.gemfile b/gemfiles/ar_8.0.gemfile index 345a9246..3665ffab 100644 --- a/gemfiles/ar_8.0.gemfile +++ b/gemfiles/ar_8.0.gemfile @@ -15,7 +15,7 @@ gem "logger" gem "readline" gem "pry", "~> 0.16.0" gem "puma" -gem "rack", "~> 2.2.21" +gem "rack", "~> 2.2.22" gem "yard" gem "activerecord", "~> 8.0.0" gem "activesupport", "~> 8.0.0" diff --git a/gemfiles/ar_8.1.gemfile b/gemfiles/ar_8.1.gemfile index 4b04cf76..c04a12f9 100644 --- a/gemfiles/ar_8.1.gemfile +++ b/gemfiles/ar_8.1.gemfile @@ -15,7 +15,7 @@ gem "logger" gem "readline" gem "pry", "~> 0.16.0" gem "puma" -gem "rack", "~> 2.2.21" +gem "rack", "~> 2.2.22" gem "yard" gem "activerecord", "~> 8.1.0" gem "activesupport", "~> 8.1.0" diff --git a/lib/active_record_proxy_adapters/configuration.rb b/lib/active_record_proxy_adapters/configuration.rb index 95eb7c61..e58e72bf 100644 --- a/lib/active_record_proxy_adapters/configuration.rb +++ b/lib/active_record_proxy_adapters/configuration.rb @@ -45,22 +45,19 @@ class Configuration }.freeze # @return [Class] The context that is used to store the current request's state. - attr_reader :context_store - # @return [Proc] The timeout strategy to use for regex matching. - attr_reader :regexp_timeout_strategy - # @return [Logger] The logger to use for logging messages. - attr_reader :logger + # @return [Boolean] Whether to use cookies for cross-request stickiness. + attr_reader :context_store, :regexp_timeout_strategy, :logger, :stickiness_cookie_enabled def initialize @lock = Monitor.new - - self.cache_configuration = CacheConfiguration.new(@lock) - self.context_store = ActiveRecordProxyAdapters::Context - self.regexp_timeout_strategy = :log - self.logger = ActiveRecord::Base.logger || Logger.new($stdout) - @database_configurations = {} + self.cache_configuration = CacheConfiguration.new(@lock) + self.context_store = ActiveRecordProxyAdapters::Context + self.regexp_timeout_strategy = :log + self.logger = ActiveRecord::Base.logger || Logger.new($stdout) + self.stickiness_cookie_enabled = true + @database_configurations = {} end def log_subscriber_primary_prefix=(prefix) @@ -95,6 +92,12 @@ def checkout_timeout=(checkout_timeout) default_database_config.checkout_timeout = checkout_timeout end + def stickiness_cookie_enabled=(enabled) + synchronize_update(:stickiness_cookie_enabled, from: @stickiness_cookie_enabled, to: enabled) do + @stickiness_cookie_enabled = enabled + end + end + def logger=(logger) synchronize_update(:logger, from: @logger, to: logger) do @logger = logger diff --git a/lib/active_record_proxy_adapters/middleware.rb b/lib/active_record_proxy_adapters/middleware.rb index e9aaee90..5c4207dc 100644 --- a/lib/active_record_proxy_adapters/middleware.rb +++ b/lib/active_record_proxy_adapters/middleware.rb @@ -51,11 +51,15 @@ def initialize(app, cookie_options = {}) def call(env) return @app.call(env) if ignore_request?(env) - self.current_context = context_store.new(COOKIE_READER.call(env)) + self.current_context = if stickiness_cookie_enabled + context_store.new(COOKIE_READER.call(env)) + else + context_store.new({}) + end status, headers, body = @app.call(env) - update_cookie_from_context(headers) + update_cookie_from_context(headers) if stickiness_cookie_enabled [status, headers, body] end diff --git a/lib/active_record_proxy_adapters/mixin/configuration.rb b/lib/active_record_proxy_adapters/mixin/configuration.rb index e8287915..9067ef2e 100644 --- a/lib/active_record_proxy_adapters/mixin/configuration.rb +++ b/lib/active_record_proxy_adapters/mixin/configuration.rb @@ -46,6 +46,13 @@ def logger proxy_config.logger end + # Helper to retrieve whether stickiness cookies are enabled from the configuration stored in + # {ActiveRecordProxyAdapters::Configuration#stickiness_cookie_enabled}. + # @return [Boolean] + def stickiness_cookie_enabled + proxy_config.stickiness_cookie_enabled + end + # Helper to retrieve the timeout strategy from the configuration stored in # {ActiveRecordProxyAdapters::Configuration#regexp_timeout_strategy}. # @return [Proc] diff --git a/spec/active_record_proxy_adapters/configuration_spec.rb b/spec/active_record_proxy_adapters/configuration_spec.rb new file mode 100644 index 00000000..2451e46a --- /dev/null +++ b/spec/active_record_proxy_adapters/configuration_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +RSpec.describe ActiveRecordProxyAdapters::Configuration do + describe "#stickiness_cookie_enabled" do + subject(:stickiness_cookie_enabled) { configuration.stickiness_cookie_enabled } + + let(:configuration) { described_class.new } + + it "defaults to true" do + expect(stickiness_cookie_enabled).to be(true) + end + + context "when overridden" do + it "equals the overridden value" do + configuration.stickiness_cookie_enabled = false + + expect(stickiness_cookie_enabled).to be(false) + end + end + end +end diff --git a/spec/active_record_proxy_adapters/middleware_spec.rb b/spec/active_record_proxy_adapters/middleware_spec.rb index 3668b391..5c454299 100644 --- a/spec/active_record_proxy_adapters/middleware_spec.rb +++ b/spec/active_record_proxy_adapters/middleware_spec.rb @@ -91,6 +91,39 @@ def from_cookie_string(cookie_string) end end + context "when stickiness_cookie_enabled is false" do + before do + ActiveRecordProxyAdapters.config.stickiness_cookie_enabled = false + end + + after do + ActiveRecordProxyAdapters.config.stickiness_cookie_enabled = true + end + + it "does not write a Set-Cookie header" do + env = {} + _, headers, = middleware.call(env) + + expect(headers["Set-Cookie"]).to be_nil + end + + it "creates a fresh context for the request" do + env = {} + middleware.call(env) + + expect(middleware.send(:current_context)).to be_a(ActiveRecordProxyAdapters::Context) + end + + it "does not read a previous request's cookie into the context" do + cookie_hash = { "sqlite3_primary" => Time.current.utc.to_f } + env = { "HTTP_COOKIE" => to_cookie_string(cookie_hash) } + + middleware.call(env) + + expect(middleware.send(:current_context).to_h).to eq({}) + end + end + context "when request comes from rails asset prefix" do before do rails = double("Rails") # rubocop:disable RSpec/VerifiedDoubles