Skip to content

Commit 703a52b

Browse files
authored
Merge pull request #958 from flippercloud/readonly-fix
Handle cloud sync causing write on read request
2 parents f42841d + 1f4a2a1 commit 703a52b

4 files changed

Lines changed: 177 additions & 43 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
name: CI
2-
on: [push, pull_request]
2+
on: [push]
33

44
jobs:
55
test:
6-
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
76
name: Test on ruby ${{ matrix.ruby }} and rails ${{ matrix.rails }}
87
runs-on: ubuntu-latest
98
services:

.github/workflows/examples.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: Examples
2-
on: [push, pull_request]
2+
on: [push]
33
jobs:
44
test:
55
if: github.repository_owner == 'flippercloud'

lib/flipper/adapters/active_record.rb

Lines changed: 29 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,26 @@ 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+
# Use Rails' built-in method to find the class that controls the connection
278+
# This walks up the inheritance chain to find which class called connects_to
279+
if model.respond_to?(:connection_class_for_self)
280+
connection_class = model.connection_class_for_self
281+
282+
# Only use connected_to if this class actually has connects_to configured
283+
# connection_class? returns true when connects_to was called on the class
284+
if connection_class.respond_to?(:connection_class?) && connection_class.connection_class?
285+
connection_class.connected_to(role: :writing) do
286+
with_connection(model, &block)
287+
end
288+
else
289+
with_connection(model, &block)
290+
end
291+
else
292+
with_connection(model, &block)
293+
end
294+
end
295+
274296
def warned_about_value_not_text?
275297
return @warned_about_value_not_text if defined?(@warned_about_value_not_text)
276298
@warned_about_value_not_text = true

spec/flipper/adapters/active_record_spec.rb

Lines changed: 146 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -94,78 +94,78 @@
9494

9595
context "ActiveRecord connection_pool" do
9696
before do
97-
ActiveRecord::Base.connection_handler.clear_active_connections!
97+
clear_active_connections!
9898
end
9999

100100
context "#features" do
101101
it "does not hold onto connections" do
102-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(false)
102+
expect(active_connections?).to be(false)
103103
subject.features
104-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(false)
104+
expect(active_connections?).to be(false)
105105
end
106106

107107
it "does not release previously held connection" do
108108
ActiveRecord::Base.connection # establish a new connection
109-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(true)
109+
expect(active_connections?).to be(true)
110110
subject.features
111-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(true)
111+
expect(active_connections?).to be(true)
112112
end
113113
end
114114

115115
context "#get_all" do
116116
it "does not hold onto connections" do
117-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(false)
117+
expect(active_connections?).to be(false)
118118
subject.get_all
119-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(false)
119+
expect(active_connections?).to be(false)
120120
end
121121

122122
it "does not release previously held connection" do
123123
ActiveRecord::Base.connection # establish a new connection
124-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(true)
124+
expect(active_connections?).to be(true)
125125
subject.get_all
126-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(true)
126+
expect(active_connections?).to be(true)
127127
end
128128
end
129129

130130
context "#add / #remove / #clear" do
131131
let(:feature) { Flipper::Feature.new(:search, subject) }
132132

133133
it "does not hold onto connections" do
134-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(false)
134+
expect(active_connections?).to be(false)
135135
subject.add(feature)
136-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(false)
136+
expect(active_connections?).to be(false)
137137
subject.remove(feature)
138-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(false)
138+
expect(active_connections?).to be(false)
139139
subject.clear(feature)
140-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(false)
140+
expect(active_connections?).to be(false)
141141
end
142142

143143
it "does not release previously held connection" do
144144
ActiveRecord::Base.connection # establish a new connection
145-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(true)
145+
expect(active_connections?).to be(true)
146146
subject.add(feature)
147-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(true)
147+
expect(active_connections?).to be(true)
148148
subject.remove(feature)
149-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(true)
149+
expect(active_connections?).to be(true)
150150
subject.clear(feature)
151-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(true)
151+
expect(active_connections?).to be(true)
152152
end
153153
end
154154

155155
context "#get_multi" do
156156
let(:feature) { Flipper::Feature.new(:search, subject) }
157157

158158
it "does not hold onto connections" do
159-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(false)
159+
expect(active_connections?).to be(false)
160160
subject.get_multi([feature])
161-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(false)
161+
expect(active_connections?).to be(false)
162162
end
163163

164164
it "does not release previously held connection" do
165165
ActiveRecord::Base.connection # establish a new connection
166-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(true)
166+
expect(active_connections?).to be(true)
167167
subject.get_multi([feature])
168-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(true)
168+
expect(active_connections?).to be(true)
169169
end
170170
end
171171

@@ -174,20 +174,20 @@
174174
let(:gate) { feature.gate(:boolean)}
175175

176176
it "does not hold onto connections" do
177-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(false)
177+
expect(active_connections?).to be(false)
178178
subject.enable(feature, gate, gate.wrap(true))
179-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(false)
179+
expect(active_connections?).to be(false)
180180
subject.disable(feature, gate, gate.wrap(false))
181-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(false)
181+
expect(active_connections?).to be(false)
182182
end
183183

184184
it "does not release previously held connection" do
185185
ActiveRecord::Base.connection # establish a new connection
186-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(true)
186+
expect(active_connections?).to be(true)
187187
subject.enable(feature, gate, gate.wrap(true))
188-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(true)
188+
expect(active_connections?).to be(true)
189189
subject.disable(feature, gate, gate.wrap(false))
190-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(true)
190+
expect(active_connections?).to be(true)
191191
end
192192
end
193193

@@ -196,20 +196,118 @@
196196
let(:gate) { feature.gate(:group) }
197197

198198
it "does not hold onto connections" do
199-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(false)
199+
expect(active_connections?).to be(false)
200200
subject.enable(feature, gate, gate.wrap(:admin))
201-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(false)
201+
expect(active_connections?).to be(false)
202202
subject.disable(feature, gate, gate.wrap(:admin))
203-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(false)
203+
expect(active_connections?).to be(false)
204204
end
205205

206206
it "does not release previously held connection" do
207207
ActiveRecord::Base.connection # establish a new connection
208-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(true)
208+
expect(active_connections?).to be(true)
209209
subject.enable(feature, gate, gate.wrap(:admin))
210-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(true)
210+
expect(active_connections?).to be(true)
211211
subject.disable(feature, gate, gate.wrap(:admin))
212-
expect(ActiveRecord::Base.connection_handler.active_connections?).to be(true)
212+
expect(active_connections?).to be(true)
213+
end
214+
end
215+
end
216+
217+
if ActiveRecord.version >= Gem::Version.new('7.1')
218+
context 'with read/write roles' do
219+
before do
220+
skip "connected_to with roles is not supported on #{config['adapter']}" if config["adapter"] == "sqlite3"
221+
end
222+
223+
let(:abstract_class) do
224+
# Create a named abstract class (Rails requires names for connects_to)
225+
klass = Class.new(ActiveRecord::Base) do
226+
self.abstract_class = true
227+
end
228+
stub_const('TestApplicationRecord', klass)
229+
230+
# Now configure connects_to with the same database for both roles
231+
# In production, these would be different (primary/replica)
232+
klass.connects_to database: {
233+
writing: config,
234+
reading: config
235+
}
236+
237+
klass
238+
end
239+
240+
after do
241+
# Disconnect role-based connections to avoid interfering with database cleanup
242+
clear_all_connections!
243+
end
244+
245+
let(:feature_class) do
246+
klass = Class.new(abstract_class) do
247+
self.table_name = 'flipper_features'
248+
validates :key, presence: true
249+
end
250+
stub_const('TestFeature', klass)
251+
klass
252+
end
253+
254+
let(:gate_class) do
255+
klass = Class.new(abstract_class) do
256+
self.table_name = 'flipper_gates'
257+
end
258+
stub_const('TestGate', klass)
259+
klass
260+
end
261+
262+
let(:adapter_with_roles) do
263+
described_class.new(
264+
feature_class: feature_class,
265+
gate_class: gate_class
266+
)
267+
end
268+
269+
it 'can perform write operations when forced to reading role' do
270+
abstract_class.connected_to(role: :reading) do
271+
flipper = Flipper.new(adapter_with_roles)
272+
273+
feature = flipper[:test_feature]
274+
expect { feature.enable }.not_to raise_error
275+
expect(feature.enabled?).to be(true)
276+
expect { feature.disable }.not_to raise_error
277+
expect(feature.enabled?).to be(false)
278+
279+
feature = flipper[:actor_test]
280+
actor = Struct.new(:flipper_id).new(123)
281+
expect { feature.enable_actor(actor) }.not_to raise_error
282+
expect(feature.enabled?(actor)).to be(true)
283+
expect { feature.disable_actor(actor) }.not_to raise_error
284+
expect(feature.enabled?(actor)).to be(false)
285+
286+
feature = flipper[:gate_test]
287+
expect { feature.enable_percentage_of_time(50) }.not_to raise_error
288+
expect { feature.disable_percentage_of_time }.not_to raise_error
289+
feature.enable
290+
expect { feature.remove }.not_to raise_error
291+
292+
feature = flipper[:expression_test]
293+
expression = Flipper.property(:plan).eq("premium")
294+
expect { feature.enable_expression(expression) }.not_to raise_error
295+
expect(feature.expression).to eq(expression)
296+
expect { feature.disable_expression }.not_to raise_error
297+
expect(feature.expression).to be_nil
298+
end
299+
end
300+
301+
it 'does not hold onto connections during write operations' do
302+
clear_active_connections!
303+
304+
abstract_class.connected_to(role: :reading) do
305+
flipper = Flipper.new(adapter_with_roles)
306+
feature = flipper[:connection_test]
307+
308+
feature.enable
309+
expect(active_connections?).to be(false)
310+
end
213311
end
214312
end
215313
end
@@ -265,4 +363,19 @@
265363
end
266364
end
267365
end
366+
367+
def active_connections?
368+
method = ActiveRecord::Base.connection_handler.method(:active_connections?)
369+
method.arity == 0 ? method.call : method.call(:all)
370+
end
371+
372+
def clear_active_connections!
373+
method = ActiveRecord::Base.connection_handler.method(:clear_active_connections!)
374+
method.arity == 0 ? method.call : method.call(:all)
375+
end
376+
377+
def clear_all_connections!
378+
method = ActiveRecord::Base.connection_handler.method(:clear_all_connections!)
379+
method.arity == 0 ? method.call : method.call(:all)
380+
end
268381
end

0 commit comments

Comments
 (0)