novel 0.0.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 22f3d94e1d471b8ba3371efeb003b406b2f578a64c97ceea090eb28ee80fb471
4
- data.tar.gz: 633a42944300e52901c756549d9ea9af7abf956b403a1559a4caa18ac3d73ffd
3
+ metadata.gz: 97805eb829fb3a4e45917b2d5359f76a7db5ef69272e0521367dc8630a5eb9e4
4
+ data.tar.gz: cf6b3fab6733a9ee31437756a2b87c98aa7faa9cf11889af50d7335e93affa20
5
5
  SHA512:
6
- metadata.gz: 85c2b4d3446fcc5f6517d229224dfb5c5e83ad14d12df070856bbdd80c0c265ae319d2d810f465dd3f962068af2b929b2e402c1953040e6de27bf6cfa2ce24e7
7
- data.tar.gz: bc0a1ff1f5a7c07c1317e4d5c423da2513e7ffc675661732946095c7ec243a1b3fb1a31c90952d2991436db1071d98bf4c77b80a4ce3185e57a70e72677b2cfd
6
+ metadata.gz: 1bb0e51ec359686a959c1db3975f788ff5b0b6462930d818f525c278a095afde5eb22ab16c599c706c0582931e835e0792c94d336b9ca8caa6c23556b928cadd
7
+ data.tar.gz: d7bca07c74c67183ebb2aff147adc16945181983053766ae3eb7469c3ea75947cc92a85bd5f4fe0741402a4135ee87ad79b53fc63d35636af56fab7a8e145e6f
data/Gemfile.lock CHANGED
@@ -1,13 +1,47 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- novel (0.0.0)
4
+ novel (0.3.0)
5
+ dry-monads (~> 1.3)
6
+ dry-struct (~> 1.0)
7
+ state_machines (~> 0.5)
5
8
 
6
9
  GEM
7
10
  remote: https://rubygems.org/
8
11
  specs:
12
+ concurrent-ruby (1.1.8)
13
+ connection_pool (2.2.3)
9
14
  diff-lcs (1.3)
10
- rake (10.5.0)
15
+ dry-configurable (0.12.1)
16
+ concurrent-ruby (~> 1.0)
17
+ dry-core (~> 0.5, >= 0.5.0)
18
+ dry-container (0.7.2)
19
+ concurrent-ruby (~> 1.0)
20
+ dry-configurable (~> 0.1, >= 0.1.3)
21
+ dry-core (0.5.0)
22
+ concurrent-ruby (~> 1.0)
23
+ dry-equalizer (0.3.0)
24
+ dry-inflector (0.2.0)
25
+ dry-logic (1.2.0)
26
+ concurrent-ruby (~> 1.0)
27
+ dry-core (~> 0.5, >= 0.5)
28
+ dry-monads (1.3.5)
29
+ concurrent-ruby (~> 1.0)
30
+ dry-core (~> 0.4, >= 0.4.4)
31
+ dry-equalizer
32
+ dry-struct (1.4.0)
33
+ dry-core (~> 0.5, >= 0.5)
34
+ dry-types (~> 1.5)
35
+ ice_nine (~> 0.11)
36
+ dry-types (1.5.1)
37
+ concurrent-ruby (~> 1.0)
38
+ dry-container (~> 0.3)
39
+ dry-core (~> 0.5, >= 0.5)
40
+ dry-inflector (~> 0.1, >= 0.1.2)
41
+ dry-logic (~> 1.0, >= 1.0.2)
42
+ ice_nine (0.11.2)
43
+ rake (13.0.1)
44
+ redis (4.2.1)
11
45
  rspec (3.9.0)
12
46
  rspec-core (~> 3.9.0)
13
47
  rspec-expectations (~> 3.9.0)
@@ -21,15 +55,18 @@ GEM
21
55
  diff-lcs (>= 1.2.0, < 2.0)
22
56
  rspec-support (~> 3.9.0)
23
57
  rspec-support (3.9.0)
58
+ state_machines (0.5.0)
24
59
 
25
60
  PLATFORMS
26
61
  ruby
27
62
 
28
63
  DEPENDENCIES
29
64
  bundler (~> 2.0)
65
+ connection_pool
30
66
  novel!
31
- rake (~> 10.0)
67
+ rake (~> 13.0)
68
+ redis
32
69
  rspec (~> 3.0)
33
70
 
34
71
  BUNDLED WITH
35
- 2.0.2
72
+ 2.1.4
data/README.md CHANGED
@@ -1,13 +1,25 @@
1
1
  # Novel
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/novel`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ PoC library for orchestration saga pattern. This library can provide DSL for building orchestration objects for your sagas.
4
4
 
5
- TODO: Delete this and the text above, and describe your gem
5
+ The main reason why Novel exists is personal motivation to understand SAGA pattern better and make a great tool for orchestration SAGAs in ruby.
6
+
7
+ Key concepts:
8
+
9
+ - Immutable objects only;
10
+ - No global state. It means that you need to use IoC or containers by yourself;
11
+ - Context object as a state of SAGA and only one way to get data in each command;
12
+ - Monads as a result value for each step and full saga flow;
13
+
14
+ Dependencies:
15
+
16
+ - `dry-monads` as a result values for everything. Also, saga commands works only with result monads;
17
+ - `dry-types` as a DTO builder for context object;
18
+ - `state_machines` gem as a main state machine implementation;
6
19
 
7
20
  ## Installation
8
21
 
9
22
  Add this line to your application's Gemfile:
10
-
11
23
  ```ruby
12
24
  gem 'novel'
13
25
  ```
@@ -22,7 +34,68 @@ Or install it yourself as:
22
34
 
23
35
  ## Usage
24
36
 
25
- TODO: Write usage instructions here
37
+ You can see all examples in https://github.com/davydovanton/novel/tree/master/examples folder.
38
+
39
+ ### Commands
40
+ The main object of SAGA. Each command should follow specific rules:
41
+
42
+ - One command - one business step/transaction;
43
+ - Result of each object should be a Result monad (success/failure). When the command returns failure monad Novel starts compensation flow.
44
+ - Each command should take only one argument - `context` object. In context object you can get initial params + result of each step (activity and compensation);
45
+
46
+ ### Building SAGA
47
+ For building orchestration you need to use this DSL:
48
+
49
+ ```ruby
50
+ saga = Novel.compose(logger: Logger.new(STDOUT), repository: :memory | custom_repository_object)
51
+ .build(name: :booking)
52
+ .register_step(:car, activity: { command: ReserveCar.new, retry: 3 }, compensation: { command: CancelCar.new, retry: 3 })
53
+ .register_step(:notify_hotel, activity: { command: BookHotelProducer.new }, compensation: { command: CancelHotelHandler.new, async: true })
54
+ .register_step(:handle_hotel, activity: { command: BookHotelHandler.new, async: true }, compensation: { command: CancelHotelProducer.new })
55
+ .register_step(:flight, activity: { command: BookFlight.new }, compensation: { command: CancelFlight.new })
56
+ .build
57
+
58
+ ```
59
+ #### Sync steps
60
+ Sync steps allow you to call the next step without waiting. It means that each step will call the next sync step after self-complete. It's similar to regular interactor/DO notation/dry-transaction flow. To make the sync step you don't need anything. Just put command object to `command` option:
61
+
62
+ ```ruby
63
+ # will create tools step with sync activity command and sync compensation command
64
+ .register_step(:tools, activity: { command: BookTools.new }, compensation: { command: CancelTools.new })
65
+ ```
66
+
67
+ #### Async steps
68
+ Async steps allow you to wait any time between steps. It's really useful when you're waiting for an event from another part of your system. For example, the activity command produces an event, and the next step waiting when the system consumes a specific event from the message broker. For make async step you just need to add `async: true` option to the command:
69
+
70
+ ```ruby
71
+ # will create two steps:
72
+ # - notify_hotel step with sync activity command and async compensation command
73
+ # - handle_hotel step with async activity command and sync compensation command.
74
+ .register_step(:notify_hotel, activity: { command: BookHotelProducer.new }, compensation: { command: CancelHotelHandler.new, async: true })
75
+ .register_step(:handle_hotel, activity: { command: BookHotelHandler.new, async: true }, compensation: { command: CancelHotelProducer.new })
76
+ ```
77
+
78
+ When you working with async steps you need to manually call the saga orchestration object every time to continue flow execution. For the next call you need to send `saga id` instead of params:
79
+
80
+ ```ruby
81
+
82
+ result = saga.call(params) # first orchestration call
83
+ # => Success(Context(id: uuid))
84
+ saga.call(result.value!.id) # second call for saga flow. In this case you need to put saga id form context object to continue execution flow
85
+ ```
86
+
87
+ ### Context object
88
+ Context object provides two options:
89
+ 1. getting state of saga flow: it means that you can get the current state of saga execution, returned values for each step, and other useful information.
90
+ 2. continue working with the same saga flow in a different place (other instance of an application or other part of your system). We can do it because each context object can persist in any DB. Novel allows persisting context in memory or redis.
91
+
92
+ For more information check the source code of the context object (https://github.com/davydovanton/novel/blob/master/lib/novel/context.rb)
93
+
94
+ ### Adapters
95
+
96
+ Novel allows you to persist context in any DB that you want. I implemented memory (only for sync steps) and redis (for sync and async steps).
97
+
98
+ You can create a custom repository adapter. For this check redis implementation (https://github.com/davydovanton/novel/blob/master/lib/novel/repository_adapters/redis.rb) and redis example (https://github.com/davydovanton/novel/tree/master/examples/redis) from source code.
26
99
 
27
100
  ## Development
28
101
 
@@ -32,7 +105,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
32
105
 
33
106
  ## Contributing
34
107
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/novel. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
108
+ Bug reports and pull requests are welcome on GitHub at https://github.com/davydovanton/novel. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
36
109
 
37
110
  ## License
38
111
 
@@ -40,4 +113,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
40
113
 
41
114
  ## Code of Conduct
42
115
 
43
- Everyone interacting in the Novel project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/novel/blob/master/CODE_OF_CONDUCT.md).
116
+ Everyone interacting in the Novel project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/davydovanton/novel/blob/master/CODE_OF_CONDUCT.md).
@@ -0,0 +1,71 @@
1
+ require "novel"
2
+ require 'dry/monads'
3
+
4
+ class BaseStep
5
+ include Dry::Monads[:result]
6
+
7
+ def call(context)
8
+ puts "Step #{self.class}, context: #{context.inspect}"
9
+ puts
10
+ Success(result: rand(1..100))
11
+ end
12
+ end
13
+
14
+ class ReserveCar < BaseStep
15
+ def call(context)
16
+ # sleep(rand(10))
17
+ puts "Step #{self.class}, context: #{context.inspect}"
18
+ puts
19
+ Success(result: rand(1..100))
20
+ end
21
+ end
22
+
23
+ class BookHotelProducer < BaseStep; end
24
+ class BookHotelHandler < BaseStep; end
25
+ class BookTools < BaseStep; end
26
+
27
+ class BookFlight < BaseStep
28
+ def call(context)
29
+ puts "!!! Step #{self.class} failed"
30
+ puts
31
+ Failure(failure_result: rand(1..100))
32
+ end
33
+ end
34
+
35
+ class CancelCar < BaseStep
36
+ def call(context)
37
+ # sleep(rand(10))
38
+ puts "Step #{self.class}, context: #{context.inspect}"
39
+ puts
40
+ Success(result: rand(1..100))
41
+ end
42
+ end
43
+
44
+ class CancelHotelProducer < BaseStep; end
45
+ class CancelHotelHandler < BaseStep; end
46
+ class CancelTools < BaseStep; end
47
+ class CancelFlight < BaseStep; end
48
+
49
+ saga = Novel.compose(logger: Logger.new(STDOUT), repository: :memory, timeouts: 5) # timeout in seconds
50
+ .build(name: :booking)
51
+ .register_step(:car, activity: { command: ReserveCar.new, retry: 3 }, compensation: { command: CancelCar.new, retry: 3 })
52
+ .register_step(:notify_hotel, activity: { command: BookHotelProducer.new }, compensation: { command: CancelHotelHandler.new, async: true })
53
+ .register_step(:handle_hotel, activity: { command: BookHotelHandler.new, async: true }, compensation: { command: CancelHotelProducer.new })
54
+ .register_step(:tools, activity: { command: BookTools.new }, compensation: { command: CancelTools.new })
55
+ .register_step(:flight, activity: { command: BookFlight.new }, compensation: { command: CancelFlight.new })
56
+ .build
57
+
58
+ success_result = saga.call(params: { a: 1 })
59
+
60
+ puts '*'*80
61
+ puts "WAITING EVENT"
62
+ puts '*'*80
63
+
64
+ saga.call(saga_id: success_result.value![:context].id)
65
+
66
+ puts '*'*80
67
+ puts "WAITING EVENT"
68
+ puts '*'*80
69
+
70
+ new_result = saga.call(saga_id: success_result.value![:context].id)
71
+ pp new_result.failure
@@ -0,0 +1,4 @@
1
+ require_relative './saga'
2
+
3
+ result = SAGA.call(params: { a: 1 })
4
+ pp result
@@ -0,0 +1,5 @@
1
+ require_relative './saga'
2
+
3
+ # TODO: please, use saga id from redis_success_saga_1.rb file for make it works (you can find this value in the context object)
4
+ new_result = SAGA.call(saga_id: '5db85904-b359-44ed-9672-a9cb6015676c')
5
+ pp new_result
@@ -0,0 +1,40 @@
1
+ require "novel"
2
+ require 'dry/monads'
3
+
4
+ require 'redis'
5
+ require 'connection_pool'
6
+
7
+ class BaseStep
8
+ include Dry::Monads[:result]
9
+
10
+ def call(context)
11
+ puts "Step #{self.class}, context: #{context.inspect}"
12
+ puts
13
+ Success(result: rand(1..100))
14
+ end
15
+ end
16
+
17
+ class ReserveCar < BaseStep; end
18
+ class BookHotelProducer < BaseStep; end
19
+ class BookHotelHandler < BaseStep; end
20
+ class BookTools < BaseStep; end
21
+ class BookFlight < BaseStep; end
22
+
23
+ class CancelCar < BaseStep; end
24
+ class CancelHotelProducer < BaseStep; end
25
+ class CancelHotelHandler < BaseStep; end
26
+ class CancelTools < BaseStep; end
27
+ class CancelFlight < BaseStep; end
28
+
29
+ redis = ConnectionPool.new { Redis.new }
30
+ redis_adapter = Novel::RepositoryAdapters::Redis.new(connection_pool: redis)
31
+ repository = Novel::SagaRepository.new(adapter: redis_adapter)
32
+
33
+ SAGA = Novel.compose(logger: Logger.new(STDOUT), repository: repository, timeouts: 5) # timeout in seconds
34
+ .build(name: :booking)
35
+ .register_step(:car, activity: { command: ReserveCar.new, retry: 3 }, compensation: { command: CancelCar.new, retry: 3 })
36
+ .register_step(:notify_hotel, activity: { command: BookHotelProducer.new }, compensation: { command: CancelHotelHandler.new, async: true })
37
+ .register_step(:handle_hotel, activity: { command: BookHotelHandler.new, async: true }, compensation: { command: CancelHotelProducer.new })
38
+ .register_step(:tools, activity: { command: BookTools.new }, compensation: { command: CancelTools.new })
39
+ .register_step(:flight, activity: { command: BookFlight.new }, compensation: { command: CancelFlight.new })
40
+ .build
@@ -0,0 +1,44 @@
1
+ require "novel"
2
+ require 'dry/monads'
3
+
4
+ class BaseStep
5
+ include Dry::Monads[:result]
6
+
7
+ def call(context)
8
+ puts "Step #{self.class}, context: #{context.inspect}"
9
+ puts
10
+ Success(result: rand(1..100))
11
+ end
12
+ end
13
+
14
+ class ReserveCar < BaseStep
15
+ def call(context)
16
+ puts "Step #{self.class}, context: #{context.inspect}"
17
+ puts
18
+ Success(result: rand(1..100))
19
+ end
20
+ end
21
+
22
+ class BookHotelProducer < BaseStep; end
23
+ class BookHotelHandler < BaseStep; end
24
+ class BookTools < BaseStep; end
25
+ class BookFlight < BaseStep; end
26
+
27
+ class CancelCar < BaseStep; end
28
+ class CancelHotelProducer < BaseStep; end
29
+ class CancelHotelHandler < BaseStep; end
30
+ class CancelTools < BaseStep; end
31
+ class CancelFlight < BaseStep; end
32
+
33
+ saga = Novel.compose(logger: Logger.new(STDOUT), repository: :memory) # timeout in seconds
34
+ .build(name: :booking)
35
+ .register_step(:car, activity: { command: ReserveCar.new }, compensation: { command: CancelCar.new, retry: 3 })
36
+ .register_step(:notify_hotel, activity: { command: BookHotelProducer.new }, compensation: { command: CancelHotelHandler.new })
37
+ .register_step(:handle_hotel, activity: { command: BookHotelHandler.new }, compensation: { command: CancelHotelProducer.new })
38
+ .register_step(:tools, activity: { command: BookTools.new }, compensation: { command: CancelTools.new })
39
+ .register_step(:flight, activity: { command: BookFlight.new }, compensation: { command: CancelFlight.new })
40
+ .build
41
+
42
+ result = saga.call(params: { a: 1 })
43
+
44
+ pp result
data/lib/novel.rb CHANGED
@@ -1,6 +1,28 @@
1
- require "novel/version"
1
+ require 'logger'
2
+ require 'dry/monads'
3
+ require 'securerandom'
4
+
5
+ require 'novel/state_machines/saga_status'
6
+ require 'novel/state_machines/transaction_status'
7
+
8
+ require 'novel/container'
9
+ require 'novel/workflow_builder'
10
+ require 'novel/workflow'
11
+ require 'novel/executor'
12
+ require 'novel/saga_repository'
13
+ require 'novel/saga'
14
+ require 'novel/base'
15
+ require 'novel/version'
2
16
 
3
17
  module Novel
4
18
  class Error < StandardError; end
5
- # Your code goes here...
19
+ class InvalidRepositoryError < Error; end
20
+
21
+ BASE_LOGGER = Logger.new(STDOUT)
22
+ ONE_MINUTE = 60
23
+ MEMORY_REPOSITORY = SagaRepository.new(adapter: RepositoryAdapters::Memory.new)
24
+
25
+ def self.compose(repository: MEMORY_REPOSITORY, logger: BASE_LOGGER, timeout: ONE_MINUTE, **args)
26
+ Base.new(repository: repository, logger: logger, timeout: timeout, **args)
27
+ end
6
28
  end
data/lib/novel/base.rb ADDED
@@ -0,0 +1,22 @@
1
+ module Novel
2
+ class Base
3
+ attr_reader :logger, :repository
4
+
5
+ REPOSITORIES = {
6
+ memory: SagaRepository.new(adapter: RepositoryAdapters::Memory.new)
7
+ }
8
+
9
+
10
+
11
+ def initialize(logger:, repository:, timeout:, **args)
12
+ @logger = logger
13
+ @repository = repository.is_a?(Symbol) ? REPOSITORIES[repository] : repository
14
+ raise InvalidRepositoryError.new("Repository '#{repository}' does not exist in Novel. Please, use custom object insted") unless @repository
15
+ @timeout = timeout
16
+ end
17
+
18
+ def build(name:)
19
+ WorkflowBuilder.new(name: name, repository: repository)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,17 @@
1
+ module Novel
2
+ class Container
3
+ attr_reader :_container
4
+
5
+ def initialize
6
+ @_container = {}
7
+ end
8
+
9
+ def register(key, object)
10
+ _container[key.to_s] = object
11
+ end
12
+
13
+ def resolve(key)
14
+ _container[key.to_s]
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,50 @@
1
+ require 'securerandom'
2
+ require 'dry-struct'
3
+
4
+ module Novel
5
+ class Context < Dry::Struct
6
+ module Types
7
+ include Dry.Types()
8
+
9
+ Bool = True | False
10
+ end
11
+
12
+ INIT_SAGA_STATUS = 'started'.freeze
13
+
14
+ attribute :id, Types::String
15
+ attribute :params, Types::Hash.default({}, shared: true)
16
+ attribute :saga_status, Types::String.default(INIT_SAGA_STATUS)
17
+
18
+ attribute? :last_competed_step, Types::Symbol
19
+ attribute :step_results, Types::Hash.default({}, shared: true)
20
+
21
+ attribute? :last_competed_compensation_step, Types::Symbol
22
+ attribute :compensation_step_results, Types::Hash.default({}, shared: true)
23
+
24
+ attribute :failed, Types::Bool.default(false)
25
+
26
+ def success?
27
+ !attributes[:failed]
28
+ end
29
+
30
+ def failed?
31
+ attributes[:failed]
32
+ end
33
+
34
+ def step(step)
35
+ attributes[:step_results][step]
36
+ end
37
+
38
+ def completed_steps
39
+ attributes[:step_results].keys
40
+ end
41
+
42
+ def compensation_step(step)
43
+ attributes[:compensation_step_results][step]
44
+ end
45
+
46
+ def completed_compensation_steps
47
+ attributes[:compensation_step_results].keys
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,42 @@
1
+ require 'novel/executor/activity_flow'
2
+ require 'novel/executor/compensation_flow'
3
+
4
+ module Novel
5
+ class Executor
6
+ include Dry::Monads[:result]
7
+
8
+ attr_reader :container, :repository, :activity_flow_executor, :compensation_flow_executor
9
+
10
+ def initialize(container:, repository:)
11
+ @container = container
12
+ @repository = repository
13
+
14
+ @activity_flow_executor = Novel::Executor::ActivityFlow.new(container: container, repository: repository)
15
+ @compensation_flow_executor = Novel::Executor::CompensationFlow.new(container: container, repository: repository)
16
+ end
17
+
18
+ def start_transaction(saga_id, params, first_step)
19
+ context = repository.find_or_create_context(saga_id, params)
20
+ state_machine = StateMachines::SagaStatus.build(state: context.saga_status)
21
+
22
+ if state_machine.started?
23
+ state_machine.wait
24
+ repository.persist_context(context, saga_status: state_machine.state)
25
+ return Success(status: :waiting, context: context) if first_step[:async]
26
+ end
27
+
28
+ Success(status: :pending, context: [context, state_machine])
29
+ end
30
+
31
+ def call_activity_flow(context, state_machine, steps)
32
+ activity_flow_executor.call(context, state_machine, steps)
33
+ end
34
+
35
+ def call_compensation_flow(context, state_machine, steps)
36
+ compensation_flow_executor.call(context, state_machine, steps)
37
+ end
38
+
39
+ def finish_transaction(context, state_machine)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,69 @@
1
+ module Novel
2
+ class Executor
3
+ class ActivityFlow
4
+ include Dry::Monads[:result]
5
+
6
+ attr_reader :container, :repository
7
+
8
+ def initialize(container:, repository:)
9
+ @container = container
10
+ @repository = repository
11
+ end
12
+
13
+ def call(context, state_machine, steps)
14
+ steps.each_with_index do |step, index|
15
+ result = execut_step(context, state_machine, step, steps[index + 1])
16
+
17
+ return result if result.failure? || result.value![:status] == :waiting
18
+
19
+ context = result.value![:context]
20
+ result
21
+ end
22
+
23
+ Success(status: :finished, context: context)
24
+ end
25
+
26
+ private
27
+
28
+ def execut_step(context, state_machine, step, next_step)
29
+ result = container.resolve("#{step[:name]}.activity").call(context)
30
+
31
+ if result.failure?
32
+ state_machine.ruin
33
+
34
+ new_context = repository.persist_context(
35
+ context,
36
+ failed: true,
37
+ saga_status: state_machine.state,
38
+ last_competed_compensation_step: step[:name],
39
+ compensation_step_results: context.to_h[:compensation_step_results].merge(step[:name] => result.failure)
40
+ )
41
+
42
+ return Failure(result: result, context: new_context)
43
+ end
44
+
45
+ status = transaction_status(next_step, state_machine)
46
+
47
+ Success(
48
+ status: status,
49
+ context: repository.persist_context(
50
+ context,
51
+ saga_status: state_machine.state,
52
+ last_competed_step: step[:name],
53
+ step_results: context.to_h[:step_results].merge(step[:name] => result.value!)
54
+ )
55
+ )
56
+ end
57
+
58
+ def transaction_status(next_step, state_machine)
59
+ if next_step&.fetch(:async)
60
+ state_machine.wait
61
+
62
+ :waiting
63
+ else
64
+ :processing
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,56 @@
1
+ module Novel
2
+ class Executor
3
+ class CompensationFlow
4
+ include Dry::Monads[:result]
5
+
6
+ attr_reader :container, :repository
7
+
8
+ def initialize(container:, repository:)
9
+ @container = container
10
+ @repository = repository
11
+ end
12
+
13
+ def call(context, state_machine, steps)
14
+ steps.each_with_index.map do |step, index|
15
+ result = execut_step(context, state_machine, step, steps[index + 1])
16
+ context = result.value![:context]
17
+
18
+ if result.value![:status] == :waiting
19
+ return result
20
+ else
21
+ result
22
+ end
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def execut_step(context, state_machine, step, next_step)
29
+ result = container.resolve("#{step[:name]}.compensation").call(context)
30
+ status = transaction_status(next_step, state_machine)
31
+
32
+ Success(
33
+ status: status,
34
+ result: result,
35
+ context: repository.persist_context(
36
+ context,
37
+ failed: true,
38
+ saga_status: state_machine.state,
39
+ last_competed_compensation_step: step[:name],
40
+ compensation_step_results: context.to_h[:compensation_step_results].merge(step[:name] => result.value!)
41
+ )
42
+ )
43
+ end
44
+
45
+ def transaction_status(next_step, state_machine)
46
+ if next_step&.fetch(:async)
47
+ state_machine.wait
48
+
49
+ :waiting
50
+ else
51
+ :processing
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,18 @@
1
+ module Novel
2
+ module RepositoryAdapters
3
+ class Memory
4
+ def initialize
5
+ @store = {}
6
+ end
7
+
8
+ def find_context(saga_id)
9
+ @store[saga_id]
10
+ end
11
+
12
+ def persist_context(saga_id, context)
13
+ @store[saga_id] = context
14
+ context
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,21 @@
1
+ module Novel
2
+ module RepositoryAdapters
3
+ class Redis
4
+ attr_reader :connection_pool
5
+
6
+ def initialize(connection_pool:)
7
+ @connection_pool = connection_pool
8
+ end
9
+
10
+ def find_context(saga_id)
11
+ result = connection_pool.with { |r| r.get("novel.sagas.#{saga_id}") }
12
+ result ? Marshal.load(result) : nil
13
+ end
14
+
15
+ def persist_context(saga_id, context)
16
+ connection_pool.with { |r| r.set("novel.sagas.#{saga_id}", Marshal.dump(context)) }
17
+ context
18
+ end
19
+ end
20
+ end
21
+ end
data/lib/novel/saga.rb ADDED
@@ -0,0 +1,49 @@
1
+ require "novel/context"
2
+
3
+ module Novel
4
+ class Saga
5
+ include Dry::Monads[:result]
6
+
7
+ attr_reader :workflow, :executor
8
+
9
+ def initialize(name:, workflow:, executor:)
10
+ @name = name
11
+
12
+ @workflow = workflow
13
+ @executor = executor
14
+ end
15
+
16
+ def call(params: {}, saga_id: SecureRandom.uuid)
17
+ start_result = executor.start_transaction(saga_id, params, workflow.activity_steps.first)
18
+ return start_result if start_result.value![:status] == :waiting
19
+
20
+ context, saga_state = start_result.value![:context]
21
+
22
+ if context.success?
23
+ activity_flow_result = executor.call_activity_flow(context, saga_state, workflow.activity_steps_from(context.last_competed_step))
24
+
25
+ activity_flow_result.or do |error_result|
26
+ compensation_result = sync_compensation_result_for(error_result[:context], saga_state, error_result)
27
+
28
+ Failure(status: :saga_failed, compensation_result: compensation_result, context: compensation_result.value![:context])
29
+ end
30
+ else
31
+ compensation_steps = workflow.compensation_steps_from(context.last_competed_compensation_step)
32
+ compensation_result = executor.call_compensation_flow(context, saga_state, compensation_steps)
33
+
34
+ Failure(status: :saga_failed, compensation_result: compensation_result, context: compensation_result.last.value![:context])
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def sync_compensation_result_for(context, saga_state, error_result)
41
+ if workflow.next_compensation_step(context.last_competed_compensation_step)[:async]
42
+ # TODO: saga_state.wait
43
+ Success(error_result: error_result, context: context)
44
+ else
45
+ executor.call_compensation_flow(context, saga_state, workflow.compensation_steps_from(context.last_competed_compensation_step))
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,22 @@
1
+ require 'novel/repository_adapters/memory'
2
+ require 'novel/repository_adapters/redis'
3
+
4
+ module Novel
5
+ class SagaRepository
6
+ attr_reader :adapter
7
+
8
+ def initialize(adapter: RepositoryAdapters::Memory.new)
9
+ @adapter = adapter
10
+ end
11
+
12
+ def find_or_create_context(saga_id, params)
13
+ adapter.find_context(saga_id) || adapter.persist_context(saga_id, Context.new(id: saga_id, params: params))
14
+ end
15
+
16
+ def persist_context(context, **params)
17
+ new_context = Context.new({ **context.to_h, **params })
18
+ adapter.persist_context(context.id, new_context)
19
+ new_context
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,34 @@
1
+ require 'state_machines'
2
+
3
+ module Novel
4
+ class StateMachines
5
+ class SagaStatus
6
+ state_machine initial: :started do
7
+ event :wait do
8
+ transition [:started, :processing] => :waiting
9
+ transition processing_compensation: :waiting_compensation
10
+ end
11
+
12
+ event :process do
13
+ transition [:started, :waiting] => :processing
14
+ transition waiting_compensation: :processing_compensation
15
+ end
16
+
17
+ # CONTEXT: "fail" reserved for private api of state machine
18
+ event :ruin do
19
+ transition processing: :processing_compensation
20
+ end
21
+
22
+ event :complete do
23
+ transition [:processing_compensation, :processing] => :completed
24
+ end
25
+ end
26
+
27
+ def self.build(state: nil)
28
+ sm = self.new
29
+ sm.state = state.to_s if state
30
+ sm
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,22 @@
1
+ require 'state_machines'
2
+
3
+ module Novel
4
+ class StateMachines
5
+ class TransactionStatus
6
+ state_machine initial: :waiting do
7
+ event :process do
8
+ transition blank: :processing
9
+ end
10
+
11
+ event :complete do
12
+ transition processing: :completed
13
+ end
14
+
15
+ event :wait do
16
+ transition completed: :waiting
17
+ end
18
+
19
+ end
20
+ end
21
+ end
22
+ end
data/lib/novel/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Novel
2
- VERSION = "0.0.0"
2
+ VERSION = "0.3.0"
3
3
  end
@@ -0,0 +1,72 @@
1
+ module Novel
2
+ class Workflow
3
+ attr_reader :raw, :activity_flow, :compensation_flow
4
+
5
+ FINISH_STEP = :finish
6
+
7
+ def initialize(raw:)
8
+ @raw = raw
9
+ end
10
+
11
+ def activity_steps
12
+ @activity_steps ||= raw.map { |step| { name: step[:name], async: step[:activity][:async] } }
13
+ end
14
+
15
+ def activity_steps_from(step)
16
+ if step
17
+ next_step_index = activity_flow.index(step) + 1
18
+ remaining_steps = activity_flow[next_step_index..-1]
19
+ activity_steps.select { |s, _| remaining_steps.include?(s[:name]) }
20
+ else
21
+ activity_steps
22
+ end
23
+ end
24
+
25
+ def compensation_steps
26
+ @compensation_steps ||= raw.reverse.map { |step| step[:compensation] ? { name: step[:name], async: step[:compensation][:async] } : nil }.compact
27
+ end
28
+
29
+ def compensation_steps_from(step)
30
+ # TODO: question should I call compensation logic for failed step or should I call next step in the flow?
31
+
32
+ first_compensation_step_index = calculate_compensation_index(next_compensation_step(step)[:name])
33
+ remaining_steps = compensation_flow[first_compensation_step_index..-1]
34
+ compensation_steps.select { |s, _| remaining_steps.include?(s[:name]) }
35
+ end
36
+
37
+
38
+ def next_activity_step(step_name)
39
+ # activity_flow.include?(step_name)
40
+
41
+ activity_steps.find { |s| s[:name] == get_next_by_index(activity_flow, activity_flow.index(step_name)) }
42
+ end
43
+
44
+ def next_compensation_step(step_name)
45
+ # activity_flow.include?(step_name)
46
+
47
+ compensation_steps.find { |s| s[:name] == get_next_by_index(compensation_flow, calculate_compensation_index(step_name)) }
48
+ end
49
+
50
+ private
51
+ def activity_flow
52
+ @activity_flow ||= raw.map { |step| step[:name] }
53
+ end
54
+
55
+ def compensation_flow
56
+ @compensation_flow ||= raw.reverse.map { |step| step[:compensation] ? step[:name] : nil }
57
+ end
58
+
59
+ def calculate_compensation_index(step_name)
60
+ compensation_flow.include?(step_name) ? compensation_flow.index(step_name) : activity_flow.reverse.index(step_name)
61
+ end
62
+
63
+ def get_next_by_index(list, step_index)
64
+ next_index = step_index + 1
65
+ if next_index < list.count
66
+ list[next_index] || get_next_by_index(list, step_index + 1)
67
+ else
68
+ FINISH_STEP
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,38 @@
1
+ module Novel
2
+ class WorkflowBuilder
3
+ attr_reader :name, :raw_workflow, :repository
4
+
5
+ def initialize(name:, repository:, raw_workflow: [])
6
+ @name = name
7
+ @raw_workflow = raw_workflow
8
+ @repository = repository
9
+ end
10
+
11
+ def register_step(name, activity:, compensation: nil)
12
+ self.class.new(
13
+ name: name,
14
+ repository: repository,
15
+ raw_workflow: raw_workflow + [{ name: name, activity: activity, compensation: compensation }]
16
+ )
17
+ end
18
+
19
+ def build
20
+ Saga.new(
21
+ name: name,
22
+ workflow: Workflow.new(raw: raw_workflow),
23
+ executor: Executor.new(container: build_container, repository: repository)
24
+ )
25
+ end
26
+
27
+ private
28
+
29
+ def build_container
30
+ container = Container.new
31
+ raw_workflow.each do |step|
32
+ container.register("#{step[:name]}.activity", step[:activity][:command])
33
+ container.register("#{step[:name]}.compensation", step[:compensation][:command])
34
+ end
35
+ container
36
+ end
37
+ end
38
+ end
data/novel.gemspec CHANGED
@@ -27,6 +27,14 @@ Gem::Specification.new do |spec|
27
27
  spec.require_paths = ["lib"]
28
28
 
29
29
  spec.add_development_dependency "bundler", "~> 2.0"
30
- spec.add_development_dependency "rake", "~> 10.0"
30
+ spec.add_development_dependency "rake", "~> 13.0"
31
31
  spec.add_development_dependency "rspec", "~> 3.0"
32
+
33
+ spec.add_development_dependency "connection_pool"
34
+ spec.add_development_dependency "redis"
35
+
36
+ spec.add_dependency "dry-monads", "~> 1.3"
37
+ spec.add_dependency "dry-struct", "~> 1.0"
38
+
39
+ spec.add_dependency "state_machines", "~> 0.5"
32
40
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: novel
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anton Davydov
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2019-12-07 00:00:00.000000000 Z
11
+ date: 2021-05-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -30,14 +30,14 @@ dependencies:
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '10.0'
33
+ version: '13.0'
34
34
  type: :development
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '10.0'
40
+ version: '13.0'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: rspec
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -52,6 +52,76 @@ dependencies:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
54
  version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: connection_pool
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: redis
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: dry-monads
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.3'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.3'
97
+ - !ruby/object:Gem::Dependency
98
+ name: dry-struct
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '1.0'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '1.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: state_machines
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '0.5'
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '0.5'
55
125
  description: SAGA pattern PoC
56
126
  email:
57
127
  - antondavydov.o@gmail.com
@@ -70,8 +140,27 @@ files:
70
140
  - Rakefile
71
141
  - bin/console
72
142
  - bin/setup
143
+ - examples/failure_saga.rb
144
+ - examples/redis/redis_success_saga_1.rb
145
+ - examples/redis/redis_success_saga_2.rb
146
+ - examples/redis/saga.rb
147
+ - examples/success_saga.rb
73
148
  - lib/novel.rb
149
+ - lib/novel/base.rb
150
+ - lib/novel/container.rb
151
+ - lib/novel/context.rb
152
+ - lib/novel/executor.rb
153
+ - lib/novel/executor/activity_flow.rb
154
+ - lib/novel/executor/compensation_flow.rb
155
+ - lib/novel/repository_adapters/memory.rb
156
+ - lib/novel/repository_adapters/redis.rb
157
+ - lib/novel/saga.rb
158
+ - lib/novel/saga_repository.rb
159
+ - lib/novel/state_machines/saga_status.rb
160
+ - lib/novel/state_machines/transaction_status.rb
74
161
  - lib/novel/version.rb
162
+ - lib/novel/workflow.rb
163
+ - lib/novel/workflow_builder.rb
75
164
  - novel.gemspec
76
165
  homepage: https://github.com/davydovanton/novel
77
166
  licenses:
@@ -79,7 +168,7 @@ licenses:
79
168
  metadata:
80
169
  homepage_uri: https://github.com/davydovanton/novel
81
170
  source_code_uri: https://github.com/davydovanton/novel
82
- post_install_message:
171
+ post_install_message:
83
172
  rdoc_options: []
84
173
  require_paths:
85
174
  - lib
@@ -94,8 +183,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
94
183
  - !ruby/object:Gem::Version
95
184
  version: '0'
96
185
  requirements: []
97
- rubygems_version: 3.0.6
98
- signing_key:
186
+ rubygems_version: 3.1.2
187
+ signing_key:
99
188
  specification_version: 4
100
189
  summary: SAGA pattern PoC
101
190
  test_files: []