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 +4 -4
- data/Gemfile.lock +41 -4
- data/README.md +79 -6
- data/examples/failure_saga.rb +71 -0
- data/examples/redis/redis_success_saga_1.rb +4 -0
- data/examples/redis/redis_success_saga_2.rb +5 -0
- data/examples/redis/saga.rb +40 -0
- data/examples/success_saga.rb +44 -0
- data/lib/novel.rb +24 -2
- data/lib/novel/base.rb +22 -0
- data/lib/novel/container.rb +17 -0
- data/lib/novel/context.rb +50 -0
- data/lib/novel/executor.rb +42 -0
- data/lib/novel/executor/activity_flow.rb +69 -0
- data/lib/novel/executor/compensation_flow.rb +56 -0
- data/lib/novel/repository_adapters/memory.rb +18 -0
- data/lib/novel/repository_adapters/redis.rb +21 -0
- data/lib/novel/saga.rb +49 -0
- data/lib/novel/saga_repository.rb +22 -0
- data/lib/novel/state_machines/saga_status.rb +34 -0
- data/lib/novel/state_machines/transaction_status.rb +22 -0
- data/lib/novel/version.rb +1 -1
- data/lib/novel/workflow.rb +72 -0
- data/lib/novel/workflow_builder.rb +38 -0
- data/novel.gemspec +9 -1
- metadata +97 -8
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 97805eb829fb3a4e45917b2d5359f76a7db5ef69272e0521367dc8630a5eb9e4
|
4
|
+
data.tar.gz: cf6b3fab6733a9ee31437756a2b87c98aa7faa9cf11889af50d7335e93affa20
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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.
|
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
|
-
|
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 (~>
|
67
|
+
rake (~> 13.0)
|
68
|
+
redis
|
32
69
|
rspec (~> 3.0)
|
33
70
|
|
34
71
|
BUNDLED WITH
|
35
|
-
2.
|
72
|
+
2.1.4
|
data/README.md
CHANGED
@@ -1,13 +1,25 @@
|
|
1
1
|
# Novel
|
2
2
|
|
3
|
-
|
3
|
+
PoC library for orchestration saga pattern. This library can provide DSL for building orchestration objects for your sagas.
|
4
4
|
|
5
|
-
|
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
|
-
|
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/
|
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/
|
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,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
|
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
|
-
|
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,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
@@ -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", "~>
|
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.
|
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:
|
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: '
|
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: '
|
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.
|
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: []
|