Skip to content

Commit 58d848a

Browse files
committed
Handle sync causing write on read request
This was reported by someone using cloud. If they were using database selector middleware and a read request hit when cloud wanted to sync it would error. This is ugly. There has to be a better way. I'll ask around.
1 parent b0ec079 commit 58d848a

2 files changed

Lines changed: 135 additions & 7 deletions

File tree

lib/flipper/adapters/active_record.rb

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def features
4141

4242
# Public: Adds a feature to the set of known features.
4343
def add(feature)
44-
with_connection(@feature_class) do
44+
with_write_connection(@feature_class) do
4545
@feature_class.transaction(requires_new: true) do
4646
begin
4747
# race condition, but add is only used by enable/disable which happen
@@ -60,7 +60,7 @@ def add(feature)
6060

6161
# Public: Removes a feature from the set of known features.
6262
def remove(feature)
63-
with_connection(@feature_class) do
63+
with_write_connection(@feature_class) do
6464
@feature_class.transaction do
6565
@feature_class.where(key: feature.key).destroy_all
6666
clear(feature)
@@ -71,7 +71,7 @@ def remove(feature)
7171

7272
# Public: Clears the gate values for a feature.
7373
def clear(feature)
74-
with_connection(@gate_class) { @gate_class.where(feature_key: feature.key).destroy_all }
74+
with_write_connection(@gate_class) { @gate_class.where(feature_key: feature.key).destroy_all }
7575
true
7676
end
7777

@@ -165,9 +165,11 @@ def disable(feature, gate, thing)
165165
when :integer
166166
set(feature, gate, thing)
167167
when :json
168-
delete(feature, gate)
168+
with_write_connection(@gate_class) do
169+
delete(feature, gate)
170+
end
169171
when :set
170-
with_connection(@gate_class) do
172+
with_write_connection(@gate_class) do
171173
@gate_class.where(feature_key: feature.key, key: gate.key, value: thing.value).destroy_all
172174
end
173175
else
@@ -190,7 +192,7 @@ def set(feature, gate, thing, options = {})
190192

191193
raise VALUE_TO_TEXT_WARNING if json_feature && value_not_text?
192194

193-
with_connection(@gate_class) do
195+
with_write_connection(@gate_class) do
194196
@gate_class.transaction(requires_new: true) do
195197
clear(feature) if clear_feature
196198
delete(feature, gate)
@@ -215,7 +217,7 @@ def delete(feature, gate)
215217
end
216218

217219
def enable_multi(feature, gate, thing)
218-
with_connection(@gate_class) do |connection|
220+
with_write_connection(@gate_class) do |connection|
219221
begin
220222
connection.transaction(requires_new: true) do
221223
@gate_class.create! do |g|
@@ -271,6 +273,34 @@ def with_connection(model = @feature_class, &block)
271273
model.connection_pool.with_connection(&block)
272274
end
273275

276+
def with_write_connection(model = @feature_class, &block)
277+
warn VALUE_TO_TEXT_WARNING if !warned_about_value_not_text? && value_not_text?
278+
279+
# Use connected_to for role switching if available (Rails 6.1+)
280+
# This ensures writes go to primary/write database when using read/write roles
281+
if model.respond_to?(:connected_to)
282+
begin
283+
# Find the abstract class that manages the connection
284+
# Walk up the inheritance chain to find it
285+
connection_class = model
286+
until connection_class.abstract_class? || connection_class == ::ActiveRecord::Base
287+
connection_class = connection_class.superclass
288+
break if connection_class == ::ActiveRecord::Base || !connection_class.respond_to?(:abstract_class?)
289+
end
290+
291+
connection_class.connected_to(role: :writing) do
292+
model.connection_pool.with_connection(&block)
293+
end
294+
rescue NotImplementedError
295+
# connected_to not available or not configured for roles, fall back
296+
model.connection_pool.with_connection(&block)
297+
end
298+
else
299+
# Fall back to regular connection for single database or older Rails
300+
model.connection_pool.with_connection(&block)
301+
end
302+
end
303+
274304
def warned_about_value_not_text?
275305
return @warned_about_value_not_text if defined?(@warned_about_value_not_text)
276306
@warned_about_value_not_text = true

spec/flipper/adapters/active_record_spec.rb

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,104 @@
214214
end
215215
end
216216

217+
context 'with read/write roles' do
218+
# Skip for older Rails versions that don't support connected_to with roles
219+
next unless ActiveRecord::Base.respond_to?(:connected_to) && ActiveRecord.version >= Gem::Version.new('6.1')
220+
221+
# Skip for SQLite as it doesn't handle role-based connections well with :memory: databases
222+
next if config["adapter"] == "sqlite3"
223+
224+
let(:abstract_class) do
225+
# Create a named abstract class (Rails requires names for connects_to)
226+
klass = Class.new(ActiveRecord::Base) do
227+
self.abstract_class = true
228+
end
229+
stub_const('TestApplicationRecord', klass)
230+
231+
# Now configure connects_to with the same database for both roles
232+
# In production, these would be different (primary/replica)
233+
klass.connects_to database: {
234+
writing: config,
235+
reading: config
236+
}
237+
238+
klass
239+
end
240+
241+
after do
242+
# Disconnect role-based connections to avoid interfering with database cleanup
243+
ActiveRecord::Base.connection_handler.clear_all_connections!
244+
end
245+
246+
let(:feature_class) do
247+
klass = Class.new(abstract_class) do
248+
self.table_name = 'flipper_features'
249+
validates :key, presence: true
250+
end
251+
stub_const('TestFeature', klass)
252+
klass
253+
end
254+
255+
let(:gate_class) do
256+
klass = Class.new(abstract_class) do
257+
self.table_name = 'flipper_gates'
258+
end
259+
stub_const('TestGate', klass)
260+
klass
261+
end
262+
263+
let(:adapter_with_roles) do
264+
described_class.new(
265+
feature_class: feature_class,
266+
gate_class: gate_class
267+
)
268+
end
269+
270+
it 'can perform write operations when forced to reading role' do
271+
abstract_class.connected_to(role: :reading) do
272+
flipper = Flipper.new(adapter_with_roles)
273+
274+
feature = flipper[:test_feature]
275+
expect { feature.enable }.not_to raise_error
276+
expect(feature.enabled?).to be(true)
277+
expect { feature.disable }.not_to raise_error
278+
expect(feature.enabled?).to be(false)
279+
280+
feature = flipper[:actor_test]
281+
actor = Struct.new(:flipper_id).new(123)
282+
expect { feature.enable_actor(actor) }.not_to raise_error
283+
expect(feature.enabled?(actor)).to be(true)
284+
expect { feature.disable_actor(actor) }.not_to raise_error
285+
expect(feature.enabled?(actor)).to be(false)
286+
287+
feature = flipper[:gate_test]
288+
expect { feature.enable_percentage_of_time(50) }.not_to raise_error
289+
expect { feature.disable_percentage_of_time }.not_to raise_error
290+
feature.enable
291+
expect { feature.remove }.not_to raise_error
292+
293+
feature = flipper[:expression_test]
294+
expression = Flipper.property(:plan).eq("premium")
295+
expect { feature.enable_expression(expression) }.not_to raise_error
296+
expect(feature.expression).to eq(expression)
297+
expect { feature.disable_expression }.not_to raise_error
298+
expect(feature.expression).to be_nil
299+
end
300+
end
301+
302+
it 'does not hold onto connections during write operations' do
303+
ActiveRecord::Base.connection_handler.clear_active_connections!
304+
305+
abstract_class.connected_to(role: :reading) do
306+
flipper = Flipper.new(adapter_with_roles)
307+
feature = flipper[:connection_test]
308+
309+
feature.enable
310+
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(false)
311+
end
312+
end
313+
end
314+
217315
context 'requiring "flipper-active_record"' do
218316
before do
219317
Flipper.configuration = nil

0 commit comments

Comments
 (0)