akasha 0.4.0.pre.189 → 0.4.0.pre.200
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -0
- data/.travis.yml +1 -1
- data/CHANGELOG.md +13 -8
- data/README.md +9 -2
- data/lib/akasha.rb +2 -0
- data/lib/akasha/aggregate.rb +4 -2
- data/lib/akasha/aggregate/syntax_helpers.rb +2 -2
- data/lib/akasha/checkpoint/http_event_store_checkpoint.rb +1 -4
- data/lib/akasha/command_router.rb +40 -29
- data/lib/akasha/command_router/optimistic_transactor.rb +67 -0
- data/lib/akasha/exceptions.rb +56 -0
- data/lib/akasha/recorded_event.rb +20 -0
- data/lib/akasha/repository.rb +5 -2
- data/lib/akasha/storage/http_event_store.rb +0 -1
- data/lib/akasha/storage/http_event_store/client.rb +32 -32
- data/lib/akasha/storage/http_event_store/event_serializer.rb +4 -1
- data/lib/akasha/storage/http_event_store/response_handler.rb +3 -3
- data/lib/akasha/storage/http_event_store/stream.rb +8 -3
- data/lib/akasha/storage/memory_event_store/stream.rb +19 -2
- metadata +5 -4
- data/lib/akasha/command_router/default_handler.rb +0 -19
- data/lib/akasha/storage/http_event_store/exceptions.rb +0 -27
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5188ceabcaec253c22b9e59ee21907a3288cb1105898d75a35afb2b365327f0a
|
4
|
+
data.tar.gz: eb5b504cb7305e06c081f1c0370bbf94d1bf299b26f2e8ca403e4c68b1827e5d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0198526485615fab2f0397391d8a1ca84b621fbf7e32633952c761fe48095db7557d359d4bf8f20e27632dddac6be2a11e08e109e185ef063259d11ca6ae540f
|
7
|
+
data.tar.gz: f29bae127e182faa83963e84bd653d70b4fb30a8a15053fc822895e19018f235cc7e2af1d3a2493d7ec78c826a6d647b58392ab4f686784319cdb6debd2e19f4
|
data/.rubocop.yml
CHANGED
data/.travis.yml
CHANGED
data/CHANGELOG.md
CHANGED
@@ -1,42 +1,47 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
|
-
## Version 0.4.0.
|
3
|
+
## Version 0.4.0.pre
|
4
4
|
|
5
|
-
*
|
6
|
-
|
7
|
-
|
5
|
+
* Support for optimistic concurrency for commands. Enabled by default, will raise `ConflictError` if the aggregate
|
6
|
+
handling a command is modified by another process. Will internally retry the command up to 2 times before giving up.
|
7
|
+
See `OptimisticTransactor` for a list of available options you can pass to `CommandRouter#route!`. [#17](https://github.com/bilus/akasha/pull/17)
|
8
8
|
|
9
9
|
* Control the maximum number of retries in case of network related failures. [#14](https://github.com/bilus/akasha/pull/14)
|
10
|
-
Example:
|
11
10
|
|
11
|
+
Example:
|
12
12
|
```ruby
|
13
13
|
store = Akasha::Storage::HttpEventStore.new(..., max_retries: 10)
|
14
14
|
```
|
15
15
|
|
16
|
-
|
17
|
-
## Version 0.4.0.edge1
|
18
|
-
|
19
16
|
* Optional namespacing for aggregate/projection streams and events allowing for isolation
|
20
17
|
between applications. [#12](https://github.com/bilus/akasha/pull/12)
|
18
|
+
|
21
19
|
* Fix Unhandled events in stream break aggregate loading. [Issue #5](https://github.com/bilus/akasha/issues/5)
|
22
20
|
|
21
|
+
* Fixed issue for passsing Handlers to EventRouter via constructor, when they are not wrapped in array. [#15](https://github.com/bilus/akasha/pull/15)
|
22
|
+
|
23
23
|
|
24
24
|
## Version 0.3.0
|
25
25
|
|
26
26
|
* Asynchronous event listeners (`AsyncEventRouter`). [#9](https://github.com/bilus/akasha/pull/9)
|
27
|
+
|
27
28
|
* Simplified initialization of event- and command routers. [#10](https://github.com/bilus/akasha/pull/10)
|
29
|
+
|
28
30
|
* Remove dependency on the `http_event_store` gem.
|
31
|
+
|
29
32
|
* `Event#metadata` is no longer OpenStruct. [#10](https://github.com/bilus/akasha/pull/10)
|
30
33
|
|
31
34
|
## Version 0.2.0
|
32
35
|
|
33
36
|
* Synchronous event listeners (see `examples/sinatra/app.rb`). [#4](https://github.com/bilus/akasha/pull/4)
|
37
|
+
|
34
38
|
* HTTP-based Eventstore storage. [#7](https://github.com/bilus/akasha/pull/7)
|
35
39
|
|
36
40
|
|
37
41
|
## Version 0.1.0
|
38
42
|
|
39
43
|
* Cleaner syntax for adding events to changesets: `changeset.append(:it_happened, foo: 'bar')`. [#1](https://github.com/bilus/akasha/pull/1)
|
44
|
+
|
40
45
|
* Support for command routing (`Akasha::CommandRouter`). [#2](https://github.com/bilus/akasha/pull/2)
|
41
46
|
|
42
47
|
|
data/README.md
CHANGED
@@ -47,8 +47,15 @@ This library itself makes no assumptions about any web framework, you can use it
|
|
47
47
|
- [x] Faster shutdown
|
48
48
|
- [x] Namespacing for events and aggregates and the projection
|
49
49
|
- [x] Way to control the number of retries in face of network failures
|
50
|
-
- [
|
51
|
-
- [
|
50
|
+
- [x] Version-based concurrency
|
51
|
+
- [x] Retries.
|
52
|
+
- [ ] Documentation
|
53
|
+
- [ ] Yard docs on github pages
|
54
|
+
- [ ] Quick start
|
55
|
+
- [ ] Movie theater booking
|
56
|
+
- [ ] User-defined ConflictResolver.
|
57
|
+
- [ ] Snapshots
|
58
|
+
- [ ] Telemetry (configurable backend, default: Dogstatsd)
|
52
59
|
- [ ] Socket-based Eventstore storage backend
|
53
60
|
|
54
61
|
|
data/lib/akasha.rb
CHANGED
data/lib/akasha/aggregate.rb
CHANGED
@@ -19,9 +19,10 @@ module Akasha
|
|
19
19
|
class Aggregate
|
20
20
|
include SyntaxHelpers
|
21
21
|
|
22
|
-
attr_reader :changeset
|
22
|
+
attr_reader :changeset, :revision
|
23
23
|
|
24
24
|
def initialize(id)
|
25
|
+
@revision = -1 # No stream exists.
|
25
26
|
@changeset = Changeset.new(id)
|
26
27
|
end
|
27
28
|
|
@@ -30,8 +31,9 @@ module Akasha
|
|
30
31
|
def apply_events(events)
|
31
32
|
events.each do |event|
|
32
33
|
method_name = event_handler(event)
|
33
|
-
|
34
|
+
public_send(method_name, event.data) if respond_to?(method_name)
|
34
35
|
end
|
36
|
+
@revision = events.last&.revision.to_i
|
35
37
|
end
|
36
38
|
|
37
39
|
private
|
@@ -37,9 +37,9 @@ module Akasha
|
|
37
37
|
# Aggregate instance methods.
|
38
38
|
module InstanceMethods
|
39
39
|
# Saves the aggregate.
|
40
|
-
def save!
|
40
|
+
def save!(concurrency: :none)
|
41
41
|
return if changeset.empty?
|
42
|
-
self.class.repository.save_aggregate(self)
|
42
|
+
self.class.repository.save_aggregate(self, concurrency: concurrency)
|
43
43
|
changeset.clear!
|
44
44
|
end
|
45
45
|
end
|
@@ -2,9 +2,6 @@ module Akasha
|
|
2
2
|
module Checkpoint
|
3
3
|
# Stores stream position via HTTP Eventstore API.
|
4
4
|
class HttpEventStoreCheckpoint
|
5
|
-
Error = Class.new(RuntimeError)
|
6
|
-
StreamNotFoundError = Class.new(Error)
|
7
|
-
|
8
5
|
# Creates a new checkpoint, storing position in `stream` every `interval` events.
|
9
6
|
# Use `interval` greater than zero for idempotent event listeners.
|
10
7
|
def initialize(stream, interval: 1)
|
@@ -31,7 +28,7 @@ module Akasha
|
|
31
28
|
@next_position
|
32
29
|
rescue Akasha::Storage::HttpEventStore::HttpClientError => e
|
33
30
|
raise if e.status_code != 404
|
34
|
-
raise
|
31
|
+
raise CheckpointStreamNotFoundError, "Stream cannot be checkpointed; it does not exist: #{@stream.name}"
|
35
32
|
end
|
36
33
|
|
37
34
|
protected
|
@@ -1,4 +1,4 @@
|
|
1
|
-
require_relative 'command_router/
|
1
|
+
require_relative 'command_router/optimistic_transactor'
|
2
2
|
require 'corefines/hash'
|
3
3
|
|
4
4
|
module Akasha
|
@@ -6,40 +6,51 @@ module Akasha
|
|
6
6
|
class CommandRouter
|
7
7
|
using Corefines::Hash
|
8
8
|
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
def initialize(routes = {})
|
13
|
-
@routes = routes.flat_map do |command, target|
|
14
|
-
if target.is_a?(Class)
|
15
|
-
{ command => DefaultHandler.new(target) }
|
16
|
-
else
|
17
|
-
{ command => target }
|
18
|
-
end
|
19
|
-
end
|
20
|
-
end
|
21
|
-
|
22
|
-
# Registers a custom route, specifying either a lambda or a block.
|
23
|
-
# If both lambda and block are specified, lambda takes precedence.
|
24
|
-
def register_route(command, lambda = nil, &block)
|
25
|
-
callable = lambda || block
|
26
|
-
@routes[command] = callable
|
9
|
+
def initialize(**routes)
|
10
|
+
@routes = routes
|
27
11
|
end
|
28
12
|
|
29
|
-
# Registers a
|
30
|
-
#
|
31
|
-
#
|
32
|
-
#
|
33
|
-
|
34
|
-
|
13
|
+
# Registers a handler.
|
14
|
+
#
|
15
|
+
# As a result, when `#route!` is called for that command, the aggregate will be
|
16
|
+
# loaded from repository, the command will be sent to the object to invoke the
|
17
|
+
# object's method, and finally the aggregate will be saved.
|
18
|
+
def register(command, aggregate_class = nil, &block)
|
19
|
+
raise ArgumentError, 'Pass either aggregate class or block' if aggregate_class && block
|
20
|
+
handler = aggregate_class || block
|
21
|
+
@routes[command] = handler
|
35
22
|
end
|
36
23
|
|
37
24
|
# Routes a command to the registered target.
|
38
|
-
# Raises NotFoundError if no corresponding target can be found.
|
39
|
-
|
25
|
+
# Raises `NotFoundError` if no corresponding target can be found.
|
26
|
+
#
|
27
|
+
# Arguments:
|
28
|
+
# - command - name of the command
|
29
|
+
# - aggregate_id - aggregate id
|
30
|
+
# - options - flags:
|
31
|
+
# - transactor - transactor instance to replace the default one (`OptimisticTransactor`);
|
32
|
+
# See docs for `OptimisticTransactor` for a list of additional supported options.
|
33
|
+
def route!(command, aggregate_id, options = {}, **data)
|
40
34
|
handler = @routes[command]
|
41
|
-
|
42
|
-
|
35
|
+
case handler
|
36
|
+
when Class
|
37
|
+
transactor = options.fetch(:transactor, default_transactor)
|
38
|
+
transactor.call(handler, command, aggregate_id, options, **data)
|
39
|
+
when handler.respond_to?(:call)
|
40
|
+
handler.call(command, aggregate_id, options, **data)
|
41
|
+
when Proc
|
42
|
+
handler.call(command, aggregate_id, options, **data)
|
43
|
+
when nil
|
44
|
+
raise HandlerNotFoundError, "Handler for command #{command.inspect} not found"
|
45
|
+
else
|
46
|
+
raise UnsupportedHandlerError, "Unsupported command handler #{handler.inspect}"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def default_transactor
|
53
|
+
OptimisticTransactor.new
|
43
54
|
end
|
44
55
|
end
|
45
56
|
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
require 'retries'
|
2
|
+
|
3
|
+
module Akasha
|
4
|
+
class CommandRouter
|
5
|
+
# Default command transactor providing optional optimistic concurrency.
|
6
|
+
# Works by loading aggregate from the repo by id, having it handle the command,
|
7
|
+
# and saving changes to the aggregate to the repository.
|
8
|
+
class OptimisticTransactor
|
9
|
+
# The default maximum number of retries when conflict is detected.
|
10
|
+
MAX_CONFLICT_RETRIES = 2
|
11
|
+
# A lower limit for a retry interval.
|
12
|
+
MIN_CONFLICT_RETRY_INTERVAL = 0
|
13
|
+
# An upper limit for a retry interval.
|
14
|
+
MAX_CONFLICT_RETRY_INTERVAL = 1
|
15
|
+
|
16
|
+
# Have an aggregate handle a command.
|
17
|
+
# - `aggregate_class` - aggregate class you want to handle the command,
|
18
|
+
# - `command` - command the aggregate will process, corresponding to a method of the aggregate class.
|
19
|
+
# - `aggregate_id` - id of the aggregate instance the command is for,
|
20
|
+
# - `options`:
|
21
|
+
# - concurrency - `:optimistic` or `:none` (default: `:optimistic`);
|
22
|
+
# - revision - set to aggregate revision to detect conflicts while saving
|
23
|
+
# aggregates (requires `concurrency == :optimistic`); `nil` to just save
|
24
|
+
# without concurrency control;
|
25
|
+
# - max_conflict_retries - how many times to retry processing a command if a conflict
|
26
|
+
# is detected (`ConflictError`); default: MAX_CONFLICT_RETRIES;
|
27
|
+
# - min_conflict_retry_interval - minimum time to sleep between retries; default MIN_CO_RETRY_INTERVAL;
|
28
|
+
# - max_conflict_retry_interval - maximum time to sleep between retries; default MIN_CO_RETRY_INTERVAL.
|
29
|
+
# - `data`- command payload.
|
30
|
+
def call(aggregate_class, command, aggregate_id, options, **data)
|
31
|
+
max_conflict_retries = options.fetch(:max_conflict_retries, MAX_CONFLICT_RETRIES)
|
32
|
+
min_conflict_retry_interval = options.fetch(:min_conflict_retry_interval, MIN_CONFLICT_RETRY_INTERVAL)
|
33
|
+
max_conflict_retry_interval = options.fetch(:max_conflict_retry_interval, MAX_CONFLICT_RETRY_INTERVAL)
|
34
|
+
with_retries(base_sleep_seconds: min_conflict_retry_interval, max_sleep_seconds: max_conflict_retry_interval,
|
35
|
+
max_tries: 1 + max_conflict_retries, rescue: [Akasha::ConflictError]) do
|
36
|
+
handle_command(aggregate_class, command, aggregate_id, options, **data)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
protected
|
41
|
+
|
42
|
+
def handle_command(aggregate_class, command, aggregate_id, options, **data)
|
43
|
+
concurrency, revision = parse_options!(options)
|
44
|
+
aggregate = aggregate_class.find_or_create(aggregate_id)
|
45
|
+
check_conflict!(aggregate, revision) if concurrency == :optimistic
|
46
|
+
aggregate.public_send(command, **data)
|
47
|
+
aggregate.save!(concurrency: concurrency)
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def parse_options!(options)
|
53
|
+
concurrency = options[:concurrency] || :optimistic
|
54
|
+
revision = options[:revision]
|
55
|
+
if concurrency == :none && !revision.nil?
|
56
|
+
raise ArgumentError, "Unexpected revision #{revision.inspect} when concurrency set to #{concurrency.inspect}"
|
57
|
+
end
|
58
|
+
[concurrency, revision]
|
59
|
+
end
|
60
|
+
|
61
|
+
def check_conflict!(aggregate, revision)
|
62
|
+
return if revision.nil? || revision == aggregate.revision
|
63
|
+
raise StaleRevisionError, "Conflict detected; expected: #{revision} got: #{aggregate.revision}"
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
module Akasha
|
2
|
+
# Base exception class for all Akasha errors.
|
3
|
+
Error = Class.new(RuntimeError)
|
4
|
+
|
5
|
+
## Command routing errors
|
6
|
+
|
7
|
+
# Base exception class for errors related to routing commands.
|
8
|
+
CommandRoutingError = Class.new(Error)
|
9
|
+
|
10
|
+
# No corresponding target for a command.
|
11
|
+
HandlerNotFoundError = Class.new(CommandRoutingError)
|
12
|
+
|
13
|
+
# Type of the handler found for a command is not supported.
|
14
|
+
UnsupportedHandlerError = Class.new(CommandRoutingError)
|
15
|
+
|
16
|
+
## Concurrency errors
|
17
|
+
|
18
|
+
# Base exception class for concurrency-related errors.
|
19
|
+
ConcurrencyError = Class.new(Error)
|
20
|
+
|
21
|
+
# Stale aggregate revision number passed with command.
|
22
|
+
# It typically means that another actor already updated the
|
23
|
+
# aggregate.
|
24
|
+
StaleRevisionError = Class.new(ConcurrencyError)
|
25
|
+
|
26
|
+
# Stream modified while processing a command.
|
27
|
+
ConflictError = Class.new(ConcurrencyError)
|
28
|
+
|
29
|
+
# Missing stream when saving checkpoint.
|
30
|
+
CheckpointStreamNotFoundError = Class.new(Error)
|
31
|
+
|
32
|
+
## Storega errors
|
33
|
+
|
34
|
+
# Base class for all storage backend errors.
|
35
|
+
StorageError = Class.new(Error)
|
36
|
+
|
37
|
+
# Stream name contains invalid characters.
|
38
|
+
InvalidStreamNameError = Class.new(StorageError)
|
39
|
+
|
40
|
+
# Base class for HTTP errors.
|
41
|
+
class HttpError < StorageError
|
42
|
+
attr_reader :status_code, :response_headers
|
43
|
+
|
44
|
+
def initialize(env)
|
45
|
+
@status_code = env.status.to_i
|
46
|
+
@response_headers = env.response_headers
|
47
|
+
super("Unexpected HTTP response: #{@status_code}")
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# 4xx HTTP status code.
|
52
|
+
HttpClientError = Class.new(HttpError)
|
53
|
+
|
54
|
+
# 5xx HTTP status code.
|
55
|
+
HttpServerError = Class.new(HttpError)
|
56
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
require_relative './event'
|
2
|
+
|
3
|
+
module Akasha
|
4
|
+
# Event read from a stream.
|
5
|
+
class RecordedEvent < Event
|
6
|
+
attr_reader :revision, :updated_at
|
7
|
+
|
8
|
+
def initialize(name, id, revision, updated_at, metadata, **data)
|
9
|
+
super(name, id, metadata, **data)
|
10
|
+
@revision = revision
|
11
|
+
@updated_at = updated_at
|
12
|
+
end
|
13
|
+
|
14
|
+
def ==(other)
|
15
|
+
super(other) &&
|
16
|
+
@revision == other.revision &&
|
17
|
+
@updated_at.utc.iso8601 == other.updated_at.utc.iso8601
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
data/lib/akasha/repository.rb
CHANGED
@@ -8,6 +8,8 @@ module Akasha
|
|
8
8
|
STREAM_NAME_SEP = '-'.freeze
|
9
9
|
|
10
10
|
# Creates a new repository using the underlying `store` (e.g. `MemoryEventStore`).
|
11
|
+
# - namespace - optional namespace allowing for multiple applications to share the same Eventstore
|
12
|
+
# database without name conflicts
|
11
13
|
def initialize(store, namespace: nil)
|
12
14
|
@store = store
|
13
15
|
@subscribers = []
|
@@ -30,10 +32,11 @@ module Akasha
|
|
30
32
|
end
|
31
33
|
|
32
34
|
# Saves an aggregate to the repository, appending events to the corresponding stream.
|
33
|
-
def save_aggregate(aggregate)
|
35
|
+
def save_aggregate(aggregate, concurrency: :none)
|
34
36
|
changeset = aggregate.changeset
|
35
37
|
events = changeset.events.map { |event| event.with_metadata(namespace: @namespace) }
|
36
|
-
|
38
|
+
revision = aggregate.revision if concurrency == :optimistic
|
39
|
+
stream(aggregate.class, changeset.aggregate_id).write_events(events, revision: revision)
|
37
40
|
notify_subscribers(changeset.aggregate_id, events)
|
38
41
|
end
|
39
42
|
|
@@ -34,9 +34,9 @@ module Akasha
|
|
34
34
|
end
|
35
35
|
|
36
36
|
# Append events to stream, idempotently retrying_on_network_failures up to `max_retries`
|
37
|
-
def retry_append_to_stream(stream_name, events,
|
37
|
+
def retry_append_to_stream(stream_name, events, expected_revision = nil, max_retries: 0)
|
38
38
|
retrying_on_network_failures(max_retries) do
|
39
|
-
append_to_stream(stream_name, events,
|
39
|
+
append_to_stream(stream_name, events, expected_revision)
|
40
40
|
end
|
41
41
|
end
|
42
42
|
|
@@ -98,58 +98,58 @@ module Akasha
|
|
98
98
|
end
|
99
99
|
|
100
100
|
def auth_headers
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
'Authorization' => "Basic #{auth}"
|
105
|
-
}
|
106
|
-
else
|
107
|
-
{}
|
108
|
-
end
|
101
|
+
return {} unless @username && @password
|
102
|
+
auth = Base64.urlsafe_encode64([@username, @password].join(':'))
|
103
|
+
{ 'Authorization' => "Basic #{auth}" }
|
109
104
|
end
|
110
105
|
|
111
106
|
def retrying_on_network_failures(max_retries)
|
112
|
-
with_retries(base_sleep_seconds: MIN_RETRY_INTERVAL,
|
113
|
-
max_sleep_seconds: MAX_RETRY_INTERVAL,
|
107
|
+
with_retries(base_sleep_seconds: MIN_RETRY_INTERVAL, max_sleep_seconds: MAX_RETRY_INTERVAL,
|
114
108
|
max_tries: 1 + max_retries,
|
115
109
|
rescue: [Faraday::TimeoutError, Faraday::ConnectionFailed]) do
|
116
110
|
yield
|
117
111
|
end
|
118
112
|
end
|
119
113
|
|
120
|
-
def append_to_stream(stream_name, events,
|
114
|
+
def append_to_stream(stream_name, events, expected_revision)
|
121
115
|
@conn.post("/streams/#{stream_name}") do |req|
|
122
116
|
req.headers = {
|
123
117
|
'Content-Type' => 'application/vnd.eventstore.events+json',
|
124
|
-
|
118
|
+
'ES-ExpectedVersion' => expected_revision
|
125
119
|
}
|
126
120
|
req.body = to_event_data(events).to_json
|
127
121
|
end
|
122
|
+
rescue HttpClientError => e
|
123
|
+
raise unless e.status_code == 400
|
124
|
+
actual_version = e.response_headers['ES-CurrentVersion']
|
125
|
+
raise Akasha::ConflictError,
|
126
|
+
"Race condition; expected last event version: #{expected_revision} actual: #{actual_version}"
|
128
127
|
end
|
129
128
|
|
130
129
|
def safe_read_events(stream_name, start, count, poll)
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
return [] if e.status_code == 404
|
142
|
-
raise
|
143
|
-
rescue URI::InvalidURIError
|
144
|
-
raise InvalidStreamNameError, "Invalid stream name: #{stream_name}"
|
130
|
+
handling_read_exceptions(stream_name) do
|
131
|
+
resp = @conn.get("/streams/#{stream_name}/#{start}/forward/#{count}") do |req|
|
132
|
+
req.headers = {
|
133
|
+
'Accept' => 'application/json'
|
134
|
+
}
|
135
|
+
req.headers['ES-LongPoll'] = poll if poll&.positive?
|
136
|
+
req.params['embed'] = 'body'
|
137
|
+
end
|
138
|
+
to_events(resp.body['entries'])
|
139
|
+
end || []
|
145
140
|
end
|
146
141
|
|
147
142
|
def safe_read_metadata(stream_name)
|
148
|
-
|
149
|
-
|
143
|
+
handling_read_exceptions(stream_name) do
|
144
|
+
metadata = request(:get, "/streams/#{stream_name}/metadata", nil, 'Accept' => 'application/json')
|
145
|
+
metadata.symbolize_keys
|
146
|
+
end || {}
|
147
|
+
end
|
148
|
+
|
149
|
+
def handling_read_exceptions(stream_name)
|
150
|
+
yield
|
150
151
|
rescue HttpClientError => e
|
151
|
-
|
152
|
-
raise
|
152
|
+
raise unless e.status_code == 404
|
153
153
|
rescue URI::InvalidURIError
|
154
154
|
raise InvalidStreamNameError, "Invalid stream name: #{stream_name}"
|
155
155
|
end
|
@@ -24,7 +24,10 @@ module Akasha
|
|
24
24
|
es_events.map do |ev|
|
25
25
|
metadata = ev['metaData']&.symbolize_keys || {}
|
26
26
|
data = ev['data']&.symbolize_keys || {}
|
27
|
-
|
27
|
+
revision = ev['eventNumber']
|
28
|
+
updated_at = Time.iso8601(ev['updated']) if ev.key?('updated')
|
29
|
+
event = Akasha::RecordedEvent.new(ev['eventType'].to_sym, ev['eventId'], revision, updated_at,
|
30
|
+
metadata, **data)
|
28
31
|
event
|
29
32
|
end
|
30
33
|
end
|
@@ -4,11 +4,11 @@ module Akasha
|
|
4
4
|
# Handles responses from Eventstore HTTP API.
|
5
5
|
class ResponseHandler < Faraday::Response::Middleware
|
6
6
|
def on_complete(env)
|
7
|
-
case env
|
7
|
+
case env.status
|
8
8
|
when (400..499)
|
9
|
-
raise HttpClientError, env
|
9
|
+
raise HttpClientError, env
|
10
10
|
when (500..599)
|
11
|
-
raise HttpServerError, env
|
11
|
+
raise HttpServerError, env
|
12
12
|
end
|
13
13
|
end
|
14
14
|
end
|
@@ -8,6 +8,7 @@ module Akasha
|
|
8
8
|
# Create a stream object for accessing a ES stream.
|
9
9
|
# Does not create the underlying stream itself.
|
10
10
|
# Use the `max_retries` option to choose how many times to retry in case
|
11
|
+
# of network failures.
|
11
12
|
def initialize(client, stream_name, max_retries: 0)
|
12
13
|
@client = client
|
13
14
|
@name = stream_name
|
@@ -15,10 +16,14 @@ module Akasha
|
|
15
16
|
end
|
16
17
|
|
17
18
|
# Appends `events` to the stream.
|
18
|
-
#
|
19
|
-
|
19
|
+
# You can specify `revision` to use optimistic concurrency control:
|
20
|
+
# - nil - just append, no concurrency control,
|
21
|
+
# - -1 - the stream doesn't exist,
|
22
|
+
# - >= 0 - expected revision of the last event in stream.
|
23
|
+
def write_events(events, revision: nil)
|
20
24
|
return if events.empty?
|
21
|
-
|
25
|
+
expected_version = revision.nil? ? -2 : revision
|
26
|
+
@client.retry_append_to_stream(@name, events, expected_version, max_retries: @max_retries)
|
22
27
|
end
|
23
28
|
|
24
29
|
# Reads events from the stream starting from `start` inclusive.
|
@@ -12,8 +12,9 @@ module Akasha
|
|
12
12
|
end
|
13
13
|
|
14
14
|
# Appends events to the stream.
|
15
|
-
def write_events(events)
|
16
|
-
|
15
|
+
def write_events(events, revision: nil)
|
16
|
+
check_revision!(revision)
|
17
|
+
@events += to_recorded_events(@events.size, @before_write.call(events))
|
17
18
|
end
|
18
19
|
|
19
20
|
# Reads events from the stream starting from `start` inclusive.
|
@@ -32,6 +33,22 @@ module Akasha
|
|
32
33
|
def identity
|
33
34
|
->(x) { x }
|
34
35
|
end
|
36
|
+
|
37
|
+
def check_revision!(expected_revision)
|
38
|
+
return if expected_revision.nil?
|
39
|
+
actual_revision = @events.size - 1
|
40
|
+
return if expected_revision == actual_revision
|
41
|
+
raise ConflictError,
|
42
|
+
"Race condition; expected last event version: #{expected_revision} actual: #{actual_revision}"
|
43
|
+
end
|
44
|
+
|
45
|
+
def to_recorded_events(current_revision, events)
|
46
|
+
events.each_with_index.map do |event, i|
|
47
|
+
updated_at = Time.now.utc # Cheating.
|
48
|
+
RecordedEvent.new(event.name, event.id, current_revision + i,
|
49
|
+
updated_at, event.metadata, **event.data)
|
50
|
+
end
|
51
|
+
end
|
35
52
|
end
|
36
53
|
end
|
37
54
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: akasha
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.4.0.pre.
|
4
|
+
version: 0.4.0.pre.200
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Marcin Bilski
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2018-07-
|
11
|
+
date: 2018-07-04 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: corefines
|
@@ -227,16 +227,17 @@ files:
|
|
227
227
|
- lib/akasha/changeset.rb
|
228
228
|
- lib/akasha/checkpoint/http_event_store_checkpoint.rb
|
229
229
|
- lib/akasha/command_router.rb
|
230
|
-
- lib/akasha/command_router/
|
230
|
+
- lib/akasha/command_router/optimistic_transactor.rb
|
231
231
|
- lib/akasha/event.rb
|
232
232
|
- lib/akasha/event_listener.rb
|
233
233
|
- lib/akasha/event_router.rb
|
234
234
|
- lib/akasha/event_router_base.rb
|
235
|
+
- lib/akasha/exceptions.rb
|
236
|
+
- lib/akasha/recorded_event.rb
|
235
237
|
- lib/akasha/repository.rb
|
236
238
|
- lib/akasha/storage/http_event_store.rb
|
237
239
|
- lib/akasha/storage/http_event_store/client.rb
|
238
240
|
- lib/akasha/storage/http_event_store/event_serializer.rb
|
239
|
-
- lib/akasha/storage/http_event_store/exceptions.rb
|
240
241
|
- lib/akasha/storage/http_event_store/projection_manager.rb
|
241
242
|
- lib/akasha/storage/http_event_store/response_handler.rb
|
242
243
|
- lib/akasha/storage/http_event_store/stream.rb
|
@@ -1,19 +0,0 @@
|
|
1
|
-
module Akasha
|
2
|
-
class CommandRouter
|
3
|
-
# Default command handler.
|
4
|
-
# Works by loading aggregate from the repo by id,
|
5
|
-
# invoking its method `command`, passing all data,
|
6
|
-
# and saving changes to the aggregate in the end.
|
7
|
-
class DefaultHandler
|
8
|
-
def initialize(klass)
|
9
|
-
@klass = klass
|
10
|
-
end
|
11
|
-
|
12
|
-
def call(command, aggregate_id, **data)
|
13
|
-
aggregate = @klass.find_or_create(aggregate_id)
|
14
|
-
aggregate.public_send(command, **data)
|
15
|
-
aggregate.save!
|
16
|
-
end
|
17
|
-
end
|
18
|
-
end
|
19
|
-
end
|
@@ -1,27 +0,0 @@
|
|
1
|
-
module Akasha
|
2
|
-
module Storage
|
3
|
-
class HttpEventStore
|
4
|
-
# Base class for all HTTP Event store errors.
|
5
|
-
Error = Class.new(RuntimeError)
|
6
|
-
|
7
|
-
# Stream name contains invalid characters.
|
8
|
-
InvalidStreamNameError = Class.new(Error)
|
9
|
-
|
10
|
-
# Base class for HTTP errors.
|
11
|
-
class HttpError < Error
|
12
|
-
attr_reader :status_code
|
13
|
-
|
14
|
-
def initialize(status_code)
|
15
|
-
@status_code = status_code
|
16
|
-
super("Unexpected HTTP response: #{@status_code}")
|
17
|
-
end
|
18
|
-
end
|
19
|
-
|
20
|
-
# 4xx HTTP status code.
|
21
|
-
HttpClientError = Class.new(HttpError)
|
22
|
-
|
23
|
-
# 5xx HTTP status code.
|
24
|
-
HttpServerError = Class.new(HttpError)
|
25
|
-
end
|
26
|
-
end
|
27
|
-
end
|