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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4dee7c7f2fc76bfc5230f08b7bfb26c3f1affe7ddd61ae97f5ab867053a28a52
4
- data.tar.gz: 547d35288fc58ee79bdc904b62c0ece3b61288b120dff57acc12dd7c803bc0ed
3
+ metadata.gz: 5188ceabcaec253c22b9e59ee21907a3288cb1105898d75a35afb2b365327f0a
4
+ data.tar.gz: eb5b504cb7305e06c081f1c0370bbf94d1bf299b26f2e8ca403e4c68b1827e5d
5
5
  SHA512:
6
- metadata.gz: 4157d7302f8b2fdfdfc51f67680c6d3b7e62ac93ffb8cfdc45424d03a35badc3fdc3ceaa9dc8be407a671c204b3e12058e3d29b0945ea20c1040ce89519bb960
7
- data.tar.gz: e524477a7662f28f1405a363a3bf8557b5de64672f65f8b77171540396ada3b5ee300eefbfc9b514c52dd1eee6c756aef6ac16f81d3c079fb5c7261e88578191
6
+ metadata.gz: 0198526485615fab2f0397391d8a1ca84b621fbf7e32633952c761fe48095db7557d359d4bf8f20e27632dddac6be2a11e08e109e185ef063259d11ca6ae540f
7
+ data.tar.gz: f29bae127e182faa83963e84bd653d70b4fb30a8a15053fc822895e19018f235cc7e2af1d3a2493d7ec78c826a6d647b58392ab4f686784319cdb6debd2e19f4
@@ -17,3 +17,5 @@ Rails/Output:
17
17
  Enabled: false
18
18
  Style/FormatStringToken:
19
19
  Enabled: false
20
+ Metrics/ParameterLists:
21
+ Max: 6
@@ -10,7 +10,7 @@ cache: bundler
10
10
  before_install:
11
11
  - gem update --system
12
12
  - gem update bundler
13
- - bundle update
13
+ - bundle update # This is a library, build with the latest dependencies.
14
14
 
15
15
  matrix:
16
16
  include:
@@ -1,42 +1,47 @@
1
1
  # Changelog
2
2
 
3
- ## Version 0.4.0.edge3
3
+ ## Version 0.4.0.pre
4
4
 
5
- * Fixed issue for passsing Handlers to EventRouter via constructor, when they aren't wrapped in array.
6
-
7
- ## Version 0.4.0.edge2
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
- - [ ] Version-based concurrency
51
- - [ ] Telemetry (Dogstatsd)
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
 
@@ -1,4 +1,6 @@
1
+ require 'akasha/exceptions'
1
2
  require 'akasha/event'
3
+ require 'akasha/recorded_event'
2
4
  require 'akasha/aggregate'
3
5
  require 'akasha/command_router'
4
6
  require 'akasha/event_listener'
@@ -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
- send(method_name, event.data) if respond_to?(method_name)
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 StreamNotFoundError, "Stream cannot be checkpointed; it does not exist: #{@stream.name}"
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/default_handler'
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
- # Raised when no corresponding target can be found for a command.
10
- NotFoundError = Class.new(RuntimeError)
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 default route, mapping a command to an aggregate class.
30
- # As a result, when `#route!` is called for that command, the aggregate
31
- # will be loaded from repository, the command will be sent to the object
32
- # to invoke the object's method, and finally the aggregate will be saved.
33
- def register_default_route(command, aggregate_class)
34
- register_route(command, DefaultHandler.new(aggregate_class))
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
- def route!(command, aggregate_id, **data)
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
- return handler.call(command, aggregate_id, **data) if handler
42
- raise NotFoundError, "Target for command #{command.inspect} not found"
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
@@ -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
- stream(aggregate.class, changeset.aggregate_id).write_events(events)
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
 
@@ -1,6 +1,5 @@
1
1
  require_relative 'http_event_store/client'
2
2
  require_relative 'http_event_store/stream'
3
- require_relative 'http_event_store/exceptions'
4
3
 
5
4
  module Akasha
6
5
  module Storage
@@ -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, expected_version = nil, max_retries: 0)
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, expected_version)
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
- if @username && @password
102
- auth = Base64.urlsafe_encode64([@username, @password].join(':'))
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, _expected_version = nil)
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
- # 'ES-ExpectedVersion' => expected_version
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
- 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
- event_data = resp.body['entries']
139
- to_events(event_data)
140
- rescue HttpClientError => e
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
- metadata = request(:get, "/streams/#{stream_name}/metadata", nil, 'Accept' => 'application/json')
149
- metadata.symbolize_keys
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
- return {} if e.status_code == 404
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
- event = Akasha::Event.new(ev['eventType'].to_sym, ev['eventId'], metadata, **data)
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[:status]
7
+ case env.status
8
8
  when (400..499)
9
- raise HttpClientError, env.status
9
+ raise HttpClientError, env
10
10
  when (500..599)
11
- raise HttpServerError, env.status
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
- # of network failures.
19
- def write_events(events)
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
- @client.retry_append_to_stream(@name, events, max_retries: @max_retries)
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
- @events += @before_write.call(events)
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.189
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-02 00:00:00.000000000 Z
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/default_handler.rb
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