dynflow 2.0.1 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 570d04996df908b45e9d82a5ed4a634a536cfd27db2949547de33ceca5478277
4
- data.tar.gz: 169047a160646f11fae66436c79d189fbeda9dfa5d7382a6b75ae14c381d2615
3
+ metadata.gz: 4219e6a5f8d1e1829a43c97bb12a406c27e3a14fd9be7d29d795d63580789f9c
4
+ data.tar.gz: 3d9c6427c2bd1c2483844ca35e4b4a277d41d42113eac775aa9555e033a45e43
5
5
  SHA512:
6
- metadata.gz: 8d8c8cc61569bb975549e5b94f2c58dba9f65f1d56c8294e83a4f5bc163ada4dcaf19e14035d338683edc9e63750d84e3a67cc288f3dcbf1b9e811fa94e67985
7
- data.tar.gz: c54964b58bb14dd6c2bd6088190c598d60abfd2fd78cb6d1fd4932b1d819e865e5edc4e44f7dd960a4cc23ce33413c77523abd04d236cefceab7bd3537d17b02
6
+ metadata.gz: d62d9d27f2dec86e8c00f2d62bb51e3c81ae5c816fd3c59cde34518cffe7b77dc994acc999d5effd16bf19c73f35f12b6cec9ee6f9f491ba30dbb46eae149dc7
7
+ data.tar.gz: a6dcded79039250e858b1c89d2d0b3793c1de3e41408d9c60d9e49a053984f849c990940c674a367ba4a6a42429ff06f571f915d6de8206cc27a9e967a2c28ec
data/Gemfile CHANGED
@@ -4,8 +4,11 @@ source 'https://rubygems.org'
4
4
 
5
5
  gemspec
6
6
 
7
+ # multi_json 1.21.2 fixes json 3 compatibility, but requires Ruby 3.2+
8
+ gem 'json', '< 3.0' if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('3.2')
9
+
7
10
  group :concurrent_ruby_ext, optional: ENV.key?('CI') && ENV['CONCURRENT_RUBY_EXT'] != 'true' do
8
- gem 'concurrent-ruby-ext', '~> 1.1.3'
11
+ gem 'concurrent-ruby-ext', '~> 1.3.0'
9
12
  end
10
13
 
11
14
  group :pry, optional: ENV.key?('CI') do
data/dynflow.gemspec CHANGED
@@ -21,9 +21,10 @@ Gem::Specification.new do |s|
21
21
  s.required_ruby_version = '>= 3.0.0'
22
22
 
23
23
  s.add_dependency "algebrick", '~> 0.7.0'
24
- s.add_dependency "concurrent-ruby", '~> 1.1.3'
25
- s.add_dependency "concurrent-ruby-edge", '~> 0.6.0'
24
+ s.add_dependency "concurrent-ruby", '>= 1.1.3', '< 2.0'
25
+ s.add_dependency "concurrent-ruby-edge", '~> 0.7.0'
26
26
  s.add_dependency "csv", "~> 3.1"
27
+ s.add_dependency 'logger'
27
28
  s.add_dependency "msgpack", '~> 1.3', '>= 1.3.3'
28
29
  s.add_dependency "multi_json"
29
30
  s.add_dependency "sequel", '>= 4.0.0'
@@ -24,6 +24,8 @@ module Dynflow
24
24
  end
25
25
 
26
26
  def mark_to_skip
27
+ return if state == :skipped
28
+
27
29
  self.state = :skipped
28
30
  self.save
29
31
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dynflow
4
- VERSION = '2.0.1'
4
+ VERSION = '2.0.2'
5
5
  end
@@ -52,17 +52,9 @@ module Dynflow
52
52
 
53
53
  def invalidate_planning_lock(planning_lock)
54
54
  with_valid_execution_plan_for_lock(planning_lock) do |plan|
55
- plan.steps.values.each { |step| invalidate_step step }
56
-
57
- state = if plan.plan_steps.any? && plan.plan_steps.all? { |step| step.state == :success }
58
- :planned
59
- else
60
- :stopped
61
- end
62
- plan.update_state(state) if plan.state != state
63
-
55
+ state = invalidate_planning_plan(plan)
64
56
  coordinator.release(planning_lock)
65
- execute(plan.id) if plan.state == :planned
57
+ execute(plan.id) if state == :planned
66
58
  end
67
59
  end
68
60
 
@@ -140,6 +132,7 @@ module Dynflow
140
132
  invalidate_execution_lock(lock)
141
133
  end
142
134
  end
135
+ execution_plans_validity_check
143
136
  pruned = connector.prune_undeliverable_envelopes(self)
144
137
  logger.error("Pruned #{pruned} undeliverable envelopes") unless pruned.zero?
145
138
  world_invalidation_result.values.select { |result| result == :invalidated }.size
@@ -198,8 +191,54 @@ module Dynflow
198
191
  return orphaned_locks
199
192
  end
200
193
 
194
+ # Invalidates execution plans left in planning without a planning lock.
195
+ # Acquiring a new planning lock prevents recovery from racing with another
196
+ # validity check or a planning process which has just acquired the lock.
197
+ #
198
+ # @return [Array<String>] ids of invalidated execution plans
199
+ def execution_plans_validity_check
200
+ planning_lock_ids = coordinator.find_locks(class: Coordinator::PlanningLock.name).map(&:execution_plan_id)
201
+ planning_plans = persistence.find_execution_plans(filters: { state: 'planning' })
202
+ invalidated_plan_ids = planning_plans.filter_map do |plan|
203
+ next if planning_lock_ids.include?(plan.id)
204
+
205
+ plan.id if invalidate_planning_plan_without_lock(plan.id)
206
+ end
207
+
208
+ unless invalidated_plan_ids.empty?
209
+ logger.error "execution plans in planning without a planning lock found and invalidated: #{invalidated_plan_ids.inspect}"
210
+ end
211
+
212
+ invalidated_plan_ids
213
+ end
214
+
201
215
  private
202
216
 
217
+ def invalidate_planning_plan_without_lock(execution_plan_id)
218
+ state = nil
219
+ planning_lock = Coordinator::PlanningLock.new(self, execution_plan_id)
220
+ coordinator.acquire(planning_lock) do
221
+ plan = persistence.load_execution_plan(execution_plan_id)
222
+ state = invalidate_planning_plan(plan) if plan.state == :planning
223
+ end
224
+ execute(execution_plan_id) if state == :planned
225
+ !state.nil?
226
+ rescue Coordinator::LockError, KeyError
227
+ false
228
+ end
229
+
230
+ def invalidate_planning_plan(plan)
231
+ plan.steps.values.each { |step| invalidate_step step }
232
+
233
+ state = if plan.plan_steps.any? && plan.plan_steps.all? { |step| step.state == :success }
234
+ :planned
235
+ else
236
+ :stopped
237
+ end
238
+ plan.update_state(state) if plan.state != state
239
+ state
240
+ end
241
+
203
242
  def invalidate_step(step)
204
243
  if step.state == :running
205
244
  step.error = ExecutionPlan::Steps::Error.new("Abnormal termination (previous state: #{step.state})")
data/lib/dynflow/world.rb CHANGED
@@ -355,21 +355,20 @@ module Dynflow
355
355
  rescue Dynflow::Errors::FatalPersistenceError => e
356
356
  nil
357
357
  end
358
- @terminated.resolve
359
358
  true
360
359
  rescue => e
361
360
  logger.fatal(e)
362
361
  end
363
362
  end
364
- @terminating = Concurrent::Promises.future do
363
+ @terminating = Concurrent::Promises.resolvable_future
364
+ Thread.new do
365
365
  termination_future.wait(termination_timeout)
366
- end.on_resolution do
367
366
  @terminated.resolve
368
- Thread.new do
369
- logger.info 'World terminated, exiting.'
370
- Kernel.exit if @exit_on_terminate.true?
371
- end
367
+ logger.info 'World terminated, exiting.'
368
+ @terminating.fulfill(true)
369
+ Kernel.exit if @exit_on_terminate.true?
372
370
  end
371
+ @terminating
373
372
  end
374
373
  end
375
374
 
data/lib/dynflow.rb CHANGED
@@ -5,6 +5,7 @@ require 'set'
5
5
  require 'base64'
6
6
  require 'concurrent'
7
7
  require 'concurrent-edge'
8
+ require 'logger'
8
9
 
9
10
  logger = Logger.new($stderr)
10
11
  logger.level = Logger::INFO
@@ -137,21 +137,48 @@ module Dynflow
137
137
  end
138
138
 
139
139
  describe 'planning locks' do
140
+ it 'executes fully planned execution plans left in planning without a lock' do
141
+ plan = client_world.plan(Support::DummyExample::Dummy)
142
+ plan.set_state(:planning, true)
143
+ plan.save
144
+
145
+ client_world_2.perform_validity_checks
146
+
147
+ wait_for do
148
+ plan = client_world_2.persistence.load_execution_plan(plan.id)
149
+ plan.state == :stopped
150
+ end
151
+ end
152
+
153
+ it 'stops incompletely planned execution plans without a lock' do
154
+ plan = client_world.plan(Support::DummyExample::Dummy)
155
+ plan.set_state(:planning, true)
156
+ plan.save
157
+ step = plan.plan_steps.first
158
+ step.set_state(:pending, true)
159
+ step.save
160
+
161
+ client_world_2.perform_validity_checks
162
+
163
+ plan = client_world_2.persistence.load_execution_plan(plan.id)
164
+ _(plan.state).must_equal :stopped
165
+ end
166
+
140
167
  it 'releases orphaned planning locks and executes associated execution plans' do
141
168
  plan = client_world.plan(Support::DummyExample::Dummy)
142
169
  plan.set_state(:planning, true)
143
170
  plan.save
144
171
  client_world.coordinator.acquire Coordinator::PlanningLock.new(client_world, plan.id)
145
172
  executor_world.invalidate(client_world.registered_world)
146
- expected_locks = ["lock world-invalidation:#{client_world.id}",
147
- "unlock execution-plan:#{plan.id}", # planning lock
148
- "lock execution-plan:#{plan.id}", # execution lock
149
- "unlock world-invalidation:#{client_world.id}"]
150
- _(executor_world.coordinator.adapter.lock_log).must_equal(expected_locks)
151
173
  wait_for do
152
174
  plan = client_world_2.persistence.load_execution_plan(plan.id)
153
175
  plan.state == :stopped
154
176
  end
177
+ lock_log = executor_world.coordinator.adapter.lock_log
178
+ _(lock_log).must_include "lock world-invalidation:#{client_world.id}"
179
+ _(lock_log).must_include "unlock execution-plan:#{plan.id}" # planning lock
180
+ _(lock_log).must_include "lock execution-plan:#{plan.id}" # execution lock
181
+ _(lock_log).must_include "unlock world-invalidation:#{client_world.id}"
155
182
  end
156
183
 
157
184
  it 'releases orphaned planning locks and stops associated execution plans which did not finish planning' do
data/test/action_test.rb CHANGED
@@ -569,11 +569,8 @@ module Dynflow
569
569
  polling_plan.sub_plans_count == total
570
570
  end
571
571
 
572
- # Moving the clock to make the parent check on sub plans
573
- _(clock.pending_pings.count).must_equal 1
574
- clock.progress
575
-
576
572
  wait_for('the parent to realise the sub plans failed') do
573
+ clock.progress
577
574
  polling_plan = world.persistence.load_execution_plan(triggered_plan.id)
578
575
  polling_plan.state == :paused
579
576
  end
@@ -586,11 +583,8 @@ module Dynflow
586
583
  polling_plan.sub_plans_count == 2 * total
587
584
  end
588
585
 
589
- # Move the clock again
590
- _(clock.pending_pings.count).must_equal 1
591
- clock.progress
592
-
593
586
  wait_for('everything to finish successfully') do
587
+ clock.progress
594
588
  polling_plan = world.persistence.load_execution_plan(triggered_plan.id)
595
589
  polling_plan.state == :stopped && polling_plan.result == :success
596
590
  end
@@ -611,7 +605,7 @@ module Dynflow
611
605
  end
612
606
 
613
607
  # Moving the clock to make the parent check on sub plans
614
- _(clock.pending_pings.count).must_equal 1
608
+ wait_for('the parent poll to be scheduled') { clock.pending_pings.count == 1 }
615
609
  clock.progress
616
610
  _(clock.pending_pings.count).must_equal 0
617
611
 
@@ -694,7 +688,7 @@ module Dynflow
694
688
  plan.sub_plans_count == total &&
695
689
  plan.sub_plans.all? { |sub| sub.result == :success }
696
690
  end
697
- _(clock.pending_pings.count).must_equal 1
691
+ wait_for('the parent poll to be scheduled') { clock.pending_pings.count == 1 }
698
692
  clock.progress
699
693
  wait_for do
700
694
  plan = world.persistence.load_execution_plan(plan.id)
@@ -716,7 +710,7 @@ module Dynflow
716
710
  plan.entry_action.output[:planning_finished] == 1
717
711
  end
718
712
  # Poll was set during #initiate
719
- _(clock.pending_pings.count).must_equal 1
713
+ wait_for('the parent poll to be scheduled') { clock.pending_pings.count == 1 }
720
714
 
721
715
  # Wait for the sub plans to finish
722
716
  wait_for do
@@ -96,7 +96,10 @@ teardown() {
96
96
  wait_for 5 1 grep 'dynflow: Finished performing validity checks' "$(bg_output_file o1)"
97
97
 
98
98
  podman stop "$POSTGRES_CONTAINER_NAME"
99
- wait_for 60 1 grep 'dynflow: World terminated, exiting.' "$(bg_output_file o1)"
99
+ # With concurrent-ruby 1.3 the persistence error surfaces after
100
+ # MAX_RETRIES polling rounds, which pushes the termination log past a
101
+ # 60s window (observed ~58s); allow a generous margin.
102
+ wait_for 180 1 grep 'dynflow: World terminated, exiting.' "$(bg_output_file o1)"
100
103
  }
101
104
 
102
105
  @test "active orchestrator can withstand temporary pg connection drop" {
data/test/rescue_test.rb CHANGED
@@ -34,6 +34,25 @@ module Dynflow
34
34
  end
35
35
  end
36
36
 
37
+ describe 'when skipping a run step fails' do
38
+ let :execution_plan do
39
+ execute(Example::ActionWithSkip, 1, :error_on_skip)
40
+ end
41
+
42
+ it 'allows retrying the skip' do
43
+ 2.times do
44
+ plan = world.persistence.load_execution_plan(execution_plan.id)
45
+ plan.skip(plan.failed_steps.first)
46
+ world.execute(plan.id).value
47
+ end
48
+
49
+ plan = world.persistence.load_execution_plan(execution_plan.id)
50
+ _(plan.state).must_equal :paused
51
+ _(plan.run_steps.first.state).must_equal :error
52
+ _(plan.finalize_steps.first.state).must_equal :skipped
53
+ end
54
+ end
55
+
37
56
  describe 'of simple skippable action in finalize phase' do
38
57
  let :execution_plan do
39
58
  execute(Example::ActionWithSkip, 1, :error_on_finalize)
data/test/world_test.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'test_helper'
4
4
  require 'fileutils'
5
+ require 'mocha/minitest'
5
6
 
6
7
  module Dynflow
7
8
  module WorldTest
@@ -50,6 +51,29 @@ module Dynflow
50
51
  terminated_event.wait(10)
51
52
  _(terminated_event.resolved?).must_equal true
52
53
  end
54
+
55
+ it 'finishes when the global IO executor is saturated' do
56
+ io_executor = Concurrent::FixedThreadPool.new(1)
57
+ io_started = Concurrent::Event.new
58
+ io_release = Concurrent::Event.new
59
+ saturated_world = WorldFactory.create_world { |config| config.termination_timeout = 0.1 }
60
+ blocker = Concurrent::Promises.future_on(io_executor) do
61
+ io_started.set
62
+ io_release.wait
63
+ end
64
+ assert io_started.wait(1)
65
+ Concurrent.stubs(:global_io_executor).returns(io_executor)
66
+
67
+ termination = saturated_world.terminate
68
+
69
+ assert termination.wait(1)
70
+ _(saturated_world.terminated.resolved?).must_equal true
71
+ ensure
72
+ io_release&.set
73
+ blocker&.wait(1)
74
+ io_executor&.shutdown
75
+ io_executor&.wait_for_termination(10)
76
+ end
53
77
  end
54
78
  end
55
79
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dynflow
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.1
4
+ version: 2.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ivan Necas
@@ -28,30 +28,36 @@ dependencies:
28
28
  name: concurrent-ruby
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - "~>"
31
+ - - ">="
32
32
  - !ruby/object:Gem::Version
33
33
  version: 1.1.3
34
+ - - "<"
35
+ - !ruby/object:Gem::Version
36
+ version: '2.0'
34
37
  type: :runtime
35
38
  prerelease: false
36
39
  version_requirements: !ruby/object:Gem::Requirement
37
40
  requirements:
38
- - - "~>"
41
+ - - ">="
39
42
  - !ruby/object:Gem::Version
40
43
  version: 1.1.3
44
+ - - "<"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.0'
41
47
  - !ruby/object:Gem::Dependency
42
48
  name: concurrent-ruby-edge
43
49
  requirement: !ruby/object:Gem::Requirement
44
50
  requirements:
45
51
  - - "~>"
46
52
  - !ruby/object:Gem::Version
47
- version: 0.6.0
53
+ version: 0.7.0
48
54
  type: :runtime
49
55
  prerelease: false
50
56
  version_requirements: !ruby/object:Gem::Requirement
51
57
  requirements:
52
58
  - - "~>"
53
59
  - !ruby/object:Gem::Version
54
- version: 0.6.0
60
+ version: 0.7.0
55
61
  - !ruby/object:Gem::Dependency
56
62
  name: csv
57
63
  requirement: !ruby/object:Gem::Requirement
@@ -66,6 +72,20 @@ dependencies:
66
72
  - - "~>"
67
73
  - !ruby/object:Gem::Version
68
74
  version: '3.1'
75
+ - !ruby/object:Gem::Dependency
76
+ name: logger
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ type: :runtime
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
69
89
  - !ruby/object:Gem::Dependency
70
90
  name: msgpack
71
91
  requirement: !ruby/object:Gem::Requirement
@@ -745,7 +765,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
745
765
  - !ruby/object:Gem::Version
746
766
  version: '0'
747
767
  requirements: []
748
- rubygems_version: 4.0.6
768
+ rubygems_version: 4.0.20
749
769
  specification_version: 4
750
770
  summary: DYNamic workFLOW engine
751
771
  test_files: []