dynflow 0.8.1 → 0.8.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (38) hide show
  1. checksums.yaml +8 -8
  2. data/doc/pages/source/documentation/index.md +47 -12
  3. data/dynflow.gemspec +2 -2
  4. data/examples/future_execution.rb +73 -0
  5. data/lib/dynflow.rb +4 -1
  6. data/lib/dynflow/action.rb +15 -0
  7. data/lib/dynflow/config.rb +15 -1
  8. data/lib/dynflow/coordinator.rb +7 -0
  9. data/lib/dynflow/execution_plan.rb +15 -3
  10. data/lib/dynflow/execution_plan/steps/plan_step.rb +5 -1
  11. data/lib/dynflow/middleware.rb +4 -0
  12. data/lib/dynflow/middleware/stack.rb +1 -1
  13. data/lib/dynflow/middleware/world.rb +1 -1
  14. data/lib/dynflow/persistence.rb +19 -0
  15. data/lib/dynflow/persistence_adapters/abstract.rb +16 -0
  16. data/lib/dynflow/persistence_adapters/sequel.rb +31 -3
  17. data/lib/dynflow/persistence_adapters/sequel_migrations/006_fix_data_length.rb +17 -0
  18. data/lib/dynflow/persistence_adapters/sequel_migrations/007_future_execution.rb +13 -0
  19. data/lib/dynflow/scheduled_plan.rb +65 -0
  20. data/lib/dynflow/schedulers.rb +9 -0
  21. data/lib/dynflow/schedulers/abstract.rb +37 -0
  22. data/lib/dynflow/schedulers/abstract_core.rb +65 -0
  23. data/lib/dynflow/schedulers/polling.rb +32 -0
  24. data/lib/dynflow/serializers.rb +8 -0
  25. data/lib/dynflow/serializers/abstract.rb +15 -0
  26. data/lib/dynflow/serializers/noop.rb +15 -0
  27. data/lib/dynflow/version.rb +1 -1
  28. data/lib/dynflow/web/console.rb +8 -23
  29. data/lib/dynflow/web/console_helpers.rb +10 -0
  30. data/lib/dynflow/world.rb +99 -24
  31. data/test/abnormal_states_recovery_test.rb +64 -0
  32. data/test/future_execution_test.rb +114 -0
  33. data/test/middleware_test.rb +8 -2
  34. data/test/support/middleware_example.rb +11 -0
  35. data/test/test_helper.rb +1 -0
  36. data/web/views/show.erb +11 -0
  37. data/web/views/worlds.erb +19 -3
  38. metadata +19 -6
checksums.yaml CHANGED
@@ -1,15 +1,15 @@
1
1
  ---
2
2
  !binary "U0hBMQ==":
3
3
  metadata.gz: !binary |-
4
- YzQyNGMyZjRlNGM4NDQ3ODg0MjAwN2QzZDVmZDM3ZDhkYTk3YTg2OA==
4
+ MGY3ZTU0Nzk1ZTJlMjQ3YzRjMTI4ZjkzYTNkODZhYWIzYzZhYzc4ZQ==
5
5
  data.tar.gz: !binary |-
6
- ZjlkYWNiYTkzMTZmZTYwNTAyOGI0MTk1MjQ4ZmY0ODJlZDJkYWNjYw==
6
+ NTU1MDY2YTBhNjk2Mjc2YjJkNDhkM2JhYzFjYzk1ODU3ZDUzNDc2OQ==
7
7
  SHA512:
8
8
  metadata.gz: !binary |-
9
- OTA5MTVjODI5NzJjODkxNmYzY2ZhMzNjY2RjZTI2ZTY1NmZmYzhjYTZhYzIy
10
- MDQ1NmUxN2ViZDE2NmIxZTdmNTIzZmJjOWFhNjM0YzZmNDIxYjNmNzA3YmE2
11
- ZjhhMzY4MDdkMzMyZWE4ZTU0MTA1Yjk0NTYzMDAxYWJiZmY0NDg=
9
+ ZDRiZmQ2ODQ1MjEyYThmMDg4YjkyNDE1Y2Q5MTI1OWNkMDNjODlmODE5ODlk
10
+ N2Q2NDQyNzYyNDQzOWFmZGRhMjZmYTEwYWM1YTkyZTdlMDExMWY0NmU4N2Zl
11
+ MzkxYjFlZDgxY2I1MWQ4ZjhhZTgxZDY4NTI3NmRkMTBhNDE5NzE=
12
12
  data.tar.gz: !binary |-
13
- NzY2NDVmNTBmNDQwZWE3NDIwMTM4ZjA2NTUzNjUwYWE2ODhkYmJiNTcyODFl
14
- NzhmNTU1NDFjNTJiOWY0YzYwNDJhMDkyNThjYTVhZGM5NDgzYTUwOTk3NDU4
15
- Nzk4YmFmZDJiNTYyZDQ5YTdhNmFjMGQzZjI1ZDFiMTBiOGE5ZGM=
13
+ MGQyNzY3Y2QwZTZhYTQwNGIwYzY4NzgyNzg3NjVhZTliMDBmZTNjMzBkNDc0
14
+ ZTRhMGZhNGJiODEwZjIzODNlMDczMmU0ZDNlNDIwYzQyZmJkMmUzNzhjNDQz
15
+ MDRmMzMxNzdmMWZhNWRlOWE4Yjk1Nzc5Njc2MjAyMmUwNzBhMzU=
@@ -123,7 +123,7 @@ end
123
123
  ```
124
124
 
125
125
  Note that it does not have to be only other actions that are planned to run.
126
- In fact it's very common that the action plan itself, which means it will
126
+ In fact it's very common that the action plans itself, which means it will
127
127
  put its own `run` method call in the execution plan. In order to do that
128
128
  you can use `plan_self`. This could be used in MyActions::File::Destroy
129
129
  used in previous example
@@ -142,7 +142,7 @@ end
142
142
 
143
143
  In example above, it seems that `plan_self` is just shortcut to
144
144
  `plan_action MyActions::File::Destroy, filename` but it's not entirely true.
145
- Note that `plan_action` always trigger `plan` of a given action while `plan_self`
145
+ Note that `plan_action` always triggers `plan` of a given action while `plan_self`
146
146
  plans only the `run` of Action, so by using `plan_action` we'd end up in
147
147
  endless loop.
148
148
 
@@ -235,6 +235,8 @@ TriggerResult = Algebrick.type do
235
235
  PlaningFailed = type { fields! execution_plan_id: String, error: Exception }
236
236
  # Returned by #trigger when planning is successful but execution fails to start.
237
237
  ExecutionFailed = type { fields! execution_plan_id: String, error: Exception }
238
+ # Returned by #schedule when scheduling succeeded.
239
+ Scheduled = type { fields! execution_plan_id: String }
238
240
  # Returned by #trigger when planning is successful, #future will resolve after
239
241
  # ExecutionPlan is executed.
240
242
  Triggered = type { fields! execution_plan_id: String, future: Future }
@@ -268,6 +270,37 @@ def self.trigger_task(async, action, *args, &block)
268
270
  end
269
271
  ```
270
272
 
273
+ #### Scheduling
274
+
275
+ Scheduling an action means setting it up to be triggered at set time in future.
276
+ Any action can be scheduled by calling:
277
+
278
+ ```ruby
279
+ world_instance.schedule(AnAction,
280
+ { start_at: Time.now + 360, start_before: Time.now + 400 },
281
+ *args)
282
+ ```
283
+
284
+ This snippet of code would schedule `AnAction` with arguments `args` to be executed
285
+ in the time interval between `start_at` and `start_before`. Setting `start_before` to `nil`
286
+ would schedule this action without the timeout limit.
287
+
288
+ When an action is scheduled, an execution plan object is created with state set
289
+ to `scheduled`, but it doesn't run the the plan phase yet, the planning happens
290
+ when the `start_at` time comes. If the planning doesn't happen in time
291
+ (e.g. after `start_before`), the execution plan is marked as failed
292
+ (its state is set to `stopped` and result to `error`).
293
+
294
+ Since the `args` have to be saved, there must be a mechanism to safely serialize and deserialize them
295
+ in order to make them survive being saved in a database. This is handled by a serializer.
296
+ Different serializers can be set per action by overriding its `schedule` method.
297
+
298
+ Planning of the scheduled plans is handled by `Scheduler`, an object which
299
+ periodically checks for scheduled execution plans and plans them. Scheduled execution
300
+ plans don't do anything by themselves, they just wait to be picked up and planned by a Scheduler.
301
+ It means that if no scheduler is present, their planning will be delayed until a scheduler
302
+ is spawned.
303
+
271
304
  #### Plan phase
272
305
 
273
306
  Planning always uses the thread triggering the action. Plan phase
@@ -319,7 +352,7 @@ end
319
352
 
320
353
  {% info_block %}
321
354
 
322
- It's considered a good practice to use the just enough data for the
355
+ It's considered a good practice to use just enough data for the
323
356
  input for the action to perform the job. That means not too much
324
357
  (such as using ActiveRecord's attributes), as it might have
325
358
  performance impact as well as causes issues when changing the
@@ -488,8 +521,8 @@ def plan
488
521
  # so it's added to the above sequence to be executed as 4th.
489
522
  action1 = plan_action AnAction, actions_executed_sequentially.last.output
490
523
 
491
- # It's planned in default plan's concurrency scope it's executed concurrently
492
- # to about four actions.
524
+ # It's planned in default plan's concurrency scope so it's executed concurrently
525
+ # with the other four actions.
493
526
  action2 = plan_action AnAction
494
527
  end
495
528
  ```
@@ -557,7 +590,7 @@ The usual execution looks as follows, we use an ActiveRecord User as example of
557
590
  used. Potentially, saving some data that were retrieved in the `run`
558
591
  phase back to the local database.
559
592
 
560
- For that reason there are transactions around whole `plan` and `finale` phase
593
+ For that reason there are transactions around whole `plan` and `finalize` phase
561
594
  (all action's plan methods are in one transaction).
562
595
  If anything goes wrong in the `plan` phase any change made during planning to local DB is
563
596
  reverted. Same holds for finalizing, if anything goes wrong, all changes are reverted. Therefore
@@ -833,6 +866,7 @@ Each **Action phase** can be in one of the following states:
833
866
  **Execution plan** has following states:
834
867
 
835
868
  - **Pending** - Planning did not start yet.
869
+ - **Scheduled** - Scheduled for later execution, not yet planned.
836
870
  - **Planning** - It's being planned.
837
871
  - **Planned** - It've been planned, running phase did not start yet.
838
872
  - **Running** - It's running, `run` and `finalize` phases of actions are executed.
@@ -851,12 +885,12 @@ Each **Action phase** can be in one of the following states:
851
885
 
852
886
  ### Error handling
853
887
 
854
- If there is an error risen in **`plan` phase**, the error is persisted in the Action object
855
- for later inspection and it bubbles up in `World#trigger` method which was used to trigger
856
- the action leading to this error.
888
+ If an error is raised in **`plan` phase**, it is persisted in the Action object
889
+ for later inspection and it bubbles up in `World#trigger` method which was used to trigger
890
+ the action leading to this error.
857
891
  If you compare it to errors raised during `run` and `finalize` phase,
858
- there's the major difference: Those never bubble up in `trigger` because they are running
859
- in executor not in triggering Thread, they are just persisted in Action object.
892
+ there's one major difference: Those never bubble up in `trigger` because they are running
893
+ in executor not in triggering Thread, they are persisted just in the Action object.
860
894
 
861
895
  If there is an error in **`run` phase**, the execution pauses. You can inspect the error in
862
896
  [console](#console). The error may be intermittent or you may fix the problem manually. After
@@ -981,6 +1015,7 @@ client worlds: useful in production, see [develpment vs. production](#developmen
981
1015
  client requests and other worlds
982
1016
  1. **executor dispatcher** - responsible for getting requests from
983
1017
  other worlds and sending the responses
1018
+ 1. **scheduler** - responsible for planning and exectuion of scheduled tasks
984
1019
 
985
1020
  {% plantuml %}
986
1021
 
@@ -1234,7 +1269,7 @@ The persistence making sure that the serialized states of the
1234
1269
  execution plans are persisted for recovery and status tracking. The
1235
1270
  execution plan data are stored in it, with the actual state.
1236
1271
 
1237
- Unlike coordinator, the all the persisted data don't have to be
1272
+ Unlike coordinator, all the persisted data don't have to be
1238
1273
  available for all the worlds at the same time: every world needs just
1239
1274
  the data that it is actively working on. Also, all the data don't have to
1240
1275
  be fully synchronized between worlds (as long as the up-to-date data
data/dynflow.gemspec CHANGED
@@ -22,8 +22,8 @@ Gem::Specification.new do |s|
22
22
  s.add_dependency "multi_json"
23
23
  s.add_dependency "apipie-params"
24
24
  s.add_dependency "algebrick", '~> 0.7.0'
25
- s.add_dependency "concurrent-ruby", '~> 0.9.0.pre3'
26
- s.add_dependency "concurrent-ruby-edge", '~> 0.1.0.pre3'
25
+ s.add_dependency "concurrent-ruby", '~> 0.9.0'
26
+ s.add_dependency "concurrent-ruby-edge", '~> 0.1.0'
27
27
 
28
28
  s.add_development_dependency "rack-test"
29
29
  s.add_development_dependency "minitest"
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative 'example_helper'
4
+
5
+ class CustomPassedObject
6
+ attr_reader :id, :name
7
+
8
+ def initialize(id, name)
9
+ @id = id
10
+ @name = name
11
+ end
12
+ end
13
+
14
+ class CustomPassedObjectSerializer < ::Dynflow::Serializers::Abstract
15
+ def serialize(*args)
16
+ object = args.first
17
+ # Serialized output can be anything that is representable as JSON: Array, Hash...
18
+ { :id => object.id, :name => object.name }
19
+ end
20
+
21
+ def deserialize(serialized_args)
22
+ # Deserialized output must be an Array
23
+ [CustomPassedObject.new(serialized_args[:id], serialized_args[:name])]
24
+ end
25
+ end
26
+
27
+ class DelayedAction < Dynflow::Action
28
+
29
+ def schedule(schedule_options, *args)
30
+ CustomPassedObjectSerializer.new
31
+ end
32
+
33
+ def plan(passed_object)
34
+ plan_self :object_id => passed_object.id, :object_name => passed_object.name
35
+ end
36
+
37
+ def run
38
+ end
39
+
40
+ end
41
+
42
+ if $0 == __FILE__
43
+ ExampleHelper.world.action_logger.level = 1
44
+ ExampleHelper.world.logger.level = 0
45
+
46
+ past = Time.now - 200
47
+ near_future = Time.now + 29
48
+ future = Time.now + 180
49
+
50
+ object = CustomPassedObject.new(1, 'CPS')
51
+
52
+ past_plan = ExampleHelper.world.schedule(DelayedAction, { :start_at => past, :start_before => past }, object)
53
+ near_future_plan = ExampleHelper.world.schedule(DelayedAction, { :start_at => near_future, :start_before => future }, object)
54
+ future_plan = ExampleHelper.world.schedule(DelayedAction, { :start_at => future }, object)
55
+
56
+ puts <<-MSG.gsub(/^.*\|/, '')
57
+ |
58
+ | Future Execution Example
59
+ | ========================
60
+ |
61
+ | This example shows the future execution functionality of Dynflow, which allows to plan actions to be executed at set time.
62
+ |
63
+ | Execution plans:
64
+ | #{past_plan.id} is scheduled to execute before #{past} and should timeout on the first run of the scheduler.
65
+ | #{near_future_plan.id} is scheduled to execute at #{near_future} and should run successfully.
66
+ | #{future_plan.id} is scheduled to execute at #{future} and should run successfully.
67
+ |
68
+ | Visit http://localhost:4567 to see their status.
69
+ |
70
+ MSG
71
+
72
+ ExampleHelper.run_web_console
73
+ end
data/lib/dynflow.rb CHANGED
@@ -9,7 +9,7 @@ require 'concurrent-edge'
9
9
 
10
10
  logger = Logger.new($stderr)
11
11
  logger.level = Logger::INFO
12
- Concurrent.configuration.logger = lambda do |level, progname, message = nil, &block|
12
+ Concurrent.global_logger = lambda do |level, progname, message = nil, &block|
13
13
  logger.add level, message, progname, &block
14
14
  end
15
15
 
@@ -36,11 +36,14 @@ module Dynflow
36
36
  require 'dynflow/flows'
37
37
  require 'dynflow/execution_history'
38
38
  require 'dynflow/execution_plan'
39
+ require 'dynflow/scheduled_plan'
39
40
  require 'dynflow/action'
40
41
  require 'dynflow/executors'
41
42
  require 'dynflow/logger_adapters'
42
43
  require 'dynflow/world'
43
44
  require 'dynflow/connectors'
44
45
  require 'dynflow/dispatcher'
46
+ require 'dynflow/serializers'
47
+ require 'dynflow/schedulers'
45
48
  require 'dynflow/config'
46
49
  end
@@ -282,6 +282,17 @@ module Dynflow
282
282
  recursion.(input)
283
283
  end
284
284
 
285
+ def execute_schedule(schedule_options, *args)
286
+ world.middleware.execute(:schedule, self, schedule_options, *args) do
287
+ @serializer = schedule(schedule_options, *args)
288
+ end
289
+ end
290
+
291
+ def serializer
292
+ raise "The action must be scheduled in order to access the serializer" if @serializer.nil?
293
+ @serializer
294
+ end
295
+
285
296
  protected
286
297
 
287
298
  def state=(state)
@@ -298,6 +309,10 @@ module Dynflow
298
309
  @step.save
299
310
  end
300
311
 
312
+ def schedule(schedule_options, *args)
313
+ Serializers::Noop.new
314
+ end
315
+
301
316
  # @override to implement the action's *Plan phase* behaviour.
302
317
  # By default it plans itself and expects input-hash.
303
318
  # Use #plan_self and #plan_action methods to plan actions.
@@ -70,7 +70,15 @@ module Dynflow
70
70
  end
71
71
 
72
72
  config_attr :auto_rescue, Algebrick::Types::Boolean do
73
- false
73
+ true
74
+ end
75
+
76
+ config_attr :auto_validity_check, Algebrick::Types::Boolean do |world, config|
77
+ !!config.executor
78
+ end
79
+
80
+ config_attr :validity_check_timeout, Fixnum do
81
+ 5
74
82
  end
75
83
 
76
84
  config_attr :exit_on_terminate, Algebrick::Types::Boolean do
@@ -85,6 +93,12 @@ module Dynflow
85
93
  true
86
94
  end
87
95
 
96
+ config_attr :scheduler, Schedulers::Abstract, NilClass do |world|
97
+ options = { :poll_interval => 15,
98
+ :time_source => -> { Time.now.utc } }
99
+ Schedulers::Polling.new(world, options)
100
+ end
101
+
88
102
  config_attr :action_classes do
89
103
  Action.all_children
90
104
  end
@@ -157,6 +157,13 @@ module Dynflow
157
157
 
158
158
  end
159
159
 
160
+ class SchedulerLock < LockByWorld
161
+ def initialize(world)
162
+ super
163
+ @data[:id] = "scheduler"
164
+ end
165
+ end
166
+
160
167
  class WorldInvalidationLock < LockByWorld
161
168
  def initialize(world, invalidated_world)
162
169
  super(world)
@@ -16,11 +16,12 @@ module Dynflow
16
16
  :started_at, :ended_at, :execution_time, :real_time, :execution_history
17
17
 
18
18
  def self.states
19
- @states ||= [:pending, :planning, :planned, :running, :paused, :stopped]
19
+ @states ||= [:pending, :scheduled, :planning, :planned, :running, :paused, :stopped]
20
20
  end
21
21
 
22
22
  def self.state_transitions
23
- @state_transitions ||= { pending: [:planning],
23
+ @state_transitions ||= { pending: [:scheduled, :planning],
24
+ scheduled: [:planning, :stopped],
24
25
  planning: [:planned, :stopped],
25
26
  planned: [:running],
26
27
  running: [:paused, :stopped],
@@ -72,7 +73,7 @@ module Dynflow
72
73
  @started_at = Time.now
73
74
  when :stopped
74
75
  @ended_at = Time.now
75
- @real_time = @ended_at - @started_at
76
+ @real_time = @ended_at - @started_at unless @started_at.nil?
76
77
  @execution_time = compute_execution_time
77
78
  else
78
79
  # ignore
@@ -151,6 +152,17 @@ module Dynflow
151
152
  @last_step_id += 1
152
153
  end
153
154
 
155
+ def schedule(action_class, options, schedule_options, *args)
156
+ prepare(action_class, options)
157
+ execution_history.add("schedule", @world.id)
158
+ update_state :scheduled
159
+ entry_action.execute_schedule(schedule_options, args)
160
+ end
161
+
162
+ def schedule_record
163
+ @schedule_record ||= persistence.load_scheduled_plan(id)
164
+ end
165
+
154
166
  def prepare(action_class, options = {})
155
167
  options = options.dup
156
168
  caller_action = Type! options.delete(:caller_action), Dynflow::Action, NilClass
@@ -51,7 +51,7 @@ module Dynflow
51
51
  end
52
52
 
53
53
  def self.state_transitions
54
- @state_transitions ||= { pending: [:running],
54
+ @state_transitions ||= { pending: [:running, :error],
55
55
  running: [:success, :error],
56
56
  success: [],
57
57
  suspended: [],
@@ -76,6 +76,10 @@ module Dynflow
76
76
  hash[:children]
77
77
  end
78
78
 
79
+ def load_action
80
+ @action = @world.persistence.load_action(self)
81
+ end
82
+
79
83
  def initialize_action(caller_action)
80
84
  attributes = { execution_plan_id: execution_plan_id,
81
85
  id: action_id,
@@ -21,6 +21,10 @@ module Dynflow
21
21
  @stack.action or raise "the action is not available"
22
22
  end
23
23
 
24
+ def schedule(*args)
25
+ pass(*args)
26
+ end
27
+
24
28
  def run(*args)
25
29
  pass(*args)
26
30
  end
@@ -14,7 +14,7 @@ module Dynflow
14
14
  @middleware_class = Child! middleware_class, Middleware
15
15
  @middleware = middleware_class.new self
16
16
  @action = Type! action, Dynflow::Action, NilClass
17
- @method = Match! method, :plan, :run, :finalize, :plan_phase, :finalize_phase
17
+ @method = Match! method, :schedule, :plan, :run, :finalize, :plan_phase, :finalize_phase
18
18
  @next_stack = Type! next_stack, Middleware::Stack, Proc
19
19
  end
20
20
 
@@ -14,7 +14,7 @@ module Dynflow
14
14
  end
15
15
 
16
16
  def execute(method, action_or_class, *args, &block)
17
- Match! method, :plan, :run, :finalize, :plan_phase, :finalize_phase
17
+ Match! method, :schedule, :plan, :run, :finalize, :plan_phase, :finalize_phase
18
18
  if Child? action_or_class, Dynflow::Action
19
19
  action = nil
20
20
  action_class = action_or_class
@@ -49,6 +49,25 @@ module Dynflow
49
49
  adapter.save_execution_plan(execution_plan.id, execution_plan.to_hash)
50
50
  end
51
51
 
52
+ def find_past_scheduled_plans(time)
53
+ adapter.find_past_scheduled_plans(time).map do |plan|
54
+ ScheduledPlan.new_from_hash(@world, plan)
55
+ end
56
+ end
57
+
58
+ def delete_scheduled_plans(filters, batch_size = 1000)
59
+ adapter.delete_scheduled_plans(filters, batch_size)
60
+ end
61
+
62
+ def save_scheduled_plan(schedule)
63
+ adapter.save_scheduled_plan(schedule.execution_plan_uuid, schedule.to_hash)
64
+ end
65
+
66
+ def load_scheduled_plan(execution_plan_id)
67
+ hash = adapter.load_scheduled_plan(execution_plan_id)
68
+ ScheduledPlan.new_from_hash(@world, hash)
69
+ end
70
+
52
71
  def load_step(execution_plan_id, step_id, world)
53
72
  step_hash = adapter.load_step(execution_plan_id, step_id)
54
73
  ExecutionPlan::Steps::Abstract.from_hash(step_hash, execution_plan_id, world)