Skip to content

Commit e3c4655

Browse files
committed
Adjust #stop_async, #restore and and #start to be consistent in case of unexpected callback errors
1 parent ab78363 commit e3c4655

2 files changed

Lines changed: 138 additions & 25 deletions

File tree

lib/pg_eventstore/subscriptions/basic_runner.rb

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -162,28 +162,34 @@ def stop_async
162162
synchronize do
163163
return self unless @state.running? || @state.dead?
164164

165-
@state.halting!
166-
Thread.new do
167-
stopping_at = Time.now.utc
168-
halt = false
169-
loop do
170-
synchronize do
171-
# Give the runner up to @async_shutdown_time seconds for graceful shutdown
172-
@runner&.exit if Time.now.utc - stopping_at > @async_shutdown_time
165+
begin
166+
@state.halting!
167+
ensure
168+
Thread.new do
169+
stopping_at = Time.now.utc
170+
halt = false
171+
loop do
172+
synchronize do
173+
# Give the runner up to @async_shutdown_time seconds for graceful shutdown
174+
@runner&.exit if Time.now.utc - stopping_at > @async_shutdown_time
175+
176+
unless @runner&.alive?
177+
@state.stopped!
178+
callbacks.run_callbacks(:after_runner_stopped)
179+
end
180+
ensure
181+
next if @runner&.alive?
173182

174-
unless @runner&.alive?
175-
@state.stopped!
176183
@runner = nil
177-
callbacks.run_callbacks(:after_runner_stopped)
178184
halt = true
179185
end
186+
break if halt
187+
sleep 0.1
180188
end
181-
break if halt
182-
sleep 0.1
183189
end
184190
end
185-
self
186191
end
192+
self
187193
end
188194

189195
# Restores the runner after its death.
@@ -248,6 +254,7 @@ def synchronize
248254
# @return [void]
249255
def _start
250256
@state.running!
257+
ensure
251258
@runner = Thread.new do
252259
recoverable do
253260
loop do

spec/pg_eventstore/subscriptions/basic_runner_spec.rb

Lines changed: 117 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,31 @@
177177
expect(before_cbx_task).not_to have_received(:run)
178178
end
179179
end
180+
181+
describe 'error in :change_state callback' do
182+
subject do
183+
super()
184+
rescue error_class
185+
end
186+
187+
let(:error_class) { Class.new(StandardError) }
188+
189+
before do
190+
should_raise = true
191+
instance.define_callback(
192+
:change_state, :before,
193+
proc {
194+
if should_raise
195+
should_raise = false
196+
raise error_class, "That's unexpected!"
197+
end
198+
}
199+
)
200+
callbacks_definitions
201+
end
202+
203+
it_behaves_like 'asynchronous execution'
204+
end
180205
end
181206

182207
describe '#stop' do
@@ -384,16 +409,20 @@
384409
expect { subject }.to change { instance.state }.from("running").to("halting")
385410
end
386411
it 'changes the state to "stopped" after async_shutdown_time seconds' do
387-
expect { subject; sleep async_shutdown_time + test_adjustment_time }.to change {
388-
instance.state
412+
timeout = async_shutdown_time + test_adjustment_time
413+
expect { subject }.to change {
414+
dv(instance).deferred_wait(timeout: timeout) { _1.state == 'stopped' }.state
389415
}.from("running").to("stopped")
390416
end
391-
it "releases runner's thread after async_shutdown_time seconds" do
392-
expect { subject; sleep async_shutdown_time + test_adjustment_time }.to change {
393-
instance.instance_variable_get(:@runner)
417+
it "releases runner's thread pointer after async_shutdown_time seconds" do
418+
timeout = async_shutdown_time + test_adjustment_time
419+
expect { subject }.to change {
420+
dv(instance).deferred_wait(timeout: timeout) {
421+
_1.instance_variable_get(:@runner).nil?
422+
}.instance_variable_get(:@runner)
394423
}.from(instance_of(Thread)).to(nil)
395424
end
396-
it 'stops runners from processing further' do
425+
it "removes runner's thread" do
397426
subject
398427
thread = instance.instance_variable_get(:@runner)
399428
aggregate_failures do
@@ -402,6 +431,11 @@
402431
expect(Thread.list).not_to include(thread)
403432
end
404433
end
434+
it 'stops runners from processing further' do
435+
subject
436+
sleep async_shutdown_time
437+
expect { sleep run_interval }.not_to change { perform_async_results.size }
438+
end
405439
end
406440

407441
context 'when state is "dead"' do
@@ -437,19 +471,91 @@
437471
dv(instance).deferred_wait(timeout: async_shutdown_time) { _1.state == 'stopped' }.state
438472
}.from('dead').to('stopped')
439473
end
440-
it "releases runner's thread after async_shutdown_time seconds" do
441-
expect { subject; sleep async_shutdown_time }.to change {
442-
instance.instance_variable_get(:@runner)
474+
it "releases runner's thread pointer after async_shutdown_time seconds" do
475+
expect { subject }.to change {
476+
dv(instance).deferred_wait(timeout: async_shutdown_time) {
477+
_1.instance_variable_get(:@runner).nil?
478+
}.instance_variable_get(:@runner)
443479
}.from(instance_of(Thread)).to(nil)
444480
end
445-
it 'stops runners from processing further' do
481+
it "does not include runner's thread in the threads list" do
446482
subject
447483
thread = instance.instance_variable_get(:@runner)
448-
sleep 0.1
449484
expect(Thread.list).not_to include(thread)
450485
end
451486
end
452487
end
488+
489+
describe 'error in :change_state callback' do
490+
subject do
491+
super()
492+
rescue error_class
493+
end
494+
495+
let(:after_stopped_task) { double('After runner is stopped') }
496+
let(:perform_async_results) { [] }
497+
498+
let(:error_class) { Class.new(StandardError) }
499+
500+
# Adds some extra time needed ruby to apply changes from background thread to current thread
501+
let(:test_adjustment_time) { 0.2 }
502+
503+
before do
504+
allow(after_stopped_task).to receive(:run)
505+
instance.define_callback(:after_runner_stopped, :before, proc { after_stopped_task.run })
506+
instance.define_callback(:process_async, :before, proc { perform_async_results.push(:the_result) })
507+
instance.start
508+
instance.define_callback(
509+
:change_state, :before,
510+
proc {
511+
# Disable stderr outputs
512+
Thread.report_on_exception = false
513+
raise error_class, "That's unexpected!"
514+
}
515+
)
516+
dv(instance).wait_until(timeout: 0.1) { _1.state == 'running' }
517+
end
518+
519+
it 'spawns another thread to stop the current runner' do
520+
expect { subject }.to change { Thread.list.size }.by(1)
521+
end
522+
it 'does not executes :after_runner_stopped action' do
523+
subject
524+
dv(instance).wait_until(timeout: async_shutdown_time) { instance.stopped? }
525+
expect(after_stopped_task).not_to have_received(:run)
526+
end
527+
it 'changes the state to "halting"' do
528+
expect { subject }.to change { instance.state }.from("running").to("halting")
529+
end
530+
it 'changes the state to "stopped" after async_shutdown_time seconds' do
531+
timeout = async_shutdown_time + test_adjustment_time
532+
expect { subject }.to change {
533+
dv(instance).deferred_wait(timeout: timeout) { _1.state == 'stopped' }.state
534+
}.from("running").to("stopped")
535+
end
536+
it "releases runner's thread pointer after async_shutdown_time seconds" do
537+
timeout = async_shutdown_time + test_adjustment_time
538+
expect { subject }.to change {
539+
dv(instance).deferred_wait(timeout: timeout) {
540+
_1.instance_variable_get(:@runner).nil?
541+
}.instance_variable_get(:@runner)
542+
}.from(instance_of(Thread)).to(nil)
543+
end
544+
it "removes runner's thread" do
545+
subject
546+
thread = instance.instance_variable_get(:@runner)
547+
aggregate_failures do
548+
expect(Thread.list).to include(thread)
549+
sleep async_shutdown_time
550+
expect(Thread.list).not_to include(thread)
551+
end
552+
end
553+
it 'stops runners from processing further' do
554+
subject
555+
sleep async_shutdown_time
556+
expect { sleep run_interval }.not_to change { perform_async_results.size }
557+
end
558+
end
453559
end
454560

455561
describe '#restore' do

0 commit comments

Comments
 (0)