akasha 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +10 -0
- data/.travis.yml +22 -30
- data/CHANGELOG.md +7 -0
- data/Dockerfile +24 -0
- data/Gemfile +2 -2
- data/Gemfile.lock +36 -10
- data/README.md +62 -55
- data/Rakefile +8 -3
- data/akasha.gemspec +13 -6
- data/bin/console +3 -3
- data/bin/integration-tests.sh +40 -0
- data/docker/docker-compose.yml +17 -0
- data/examples/sinatra/Gemfile +3 -3
- data/examples/sinatra/Gemfile.lock +28 -6
- data/examples/sinatra/app.rb +54 -23
- data/lib/akasha.rb +2 -0
- data/lib/akasha/aggregate/syntax_helpers.rb +1 -1
- data/lib/akasha/async_event_router.rb +44 -0
- data/lib/akasha/changeset.rb +3 -1
- data/lib/akasha/checkpoint/http_event_store_checkpoint.rb +45 -0
- data/lib/akasha/command_router.rb +11 -2
- data/lib/akasha/event.rb +8 -12
- data/lib/akasha/event_router.rb +10 -33
- data/lib/akasha/event_router_base.rb +39 -0
- data/lib/akasha/repository.rb +13 -0
- data/lib/akasha/storage/http_event_store.rb +39 -5
- data/lib/akasha/storage/http_event_store/client.rb +169 -0
- data/lib/akasha/storage/http_event_store/event_serializer.rb +34 -0
- data/lib/akasha/storage/http_event_store/projection_manager.rb +67 -0
- data/lib/akasha/storage/http_event_store/response_handler.rb +17 -0
- data/lib/akasha/storage/http_event_store/stream.rb +17 -17
- data/lib/akasha/storage/memory_event_store.rb +31 -1
- data/lib/akasha/storage/memory_event_store/stream.rb +12 -2
- data/lib/akasha/version.rb +1 -1
- metadata +121 -13
@@ -0,0 +1,17 @@
|
|
1
|
+
version: '3'
|
2
|
+
|
3
|
+
services:
|
4
|
+
eventstore:
|
5
|
+
image: eventstore/eventstore
|
6
|
+
environment:
|
7
|
+
- EVENTSTORE_MEM_DB="true"
|
8
|
+
- EVENTSTORE_START_STANDARD_PROJECTIONS="true"
|
9
|
+
expose:
|
10
|
+
- "2113"
|
11
|
+
- "1113"
|
12
|
+
tests:
|
13
|
+
build: ..
|
14
|
+
links:
|
15
|
+
- eventstore
|
16
|
+
environment:
|
17
|
+
- EVENTSTORE_URL=http://admin:changeit@eventstore:2113
|
data/examples/sinatra/Gemfile
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
source
|
1
|
+
source 'https://rubygems.org'
|
2
2
|
|
3
|
-
gem '
|
4
|
-
gem '
|
3
|
+
gem 'akasha', path: '../../'
|
4
|
+
gem 'sinatra', '~>2', '>2.0.1'
|
@@ -1,24 +1,46 @@
|
|
1
|
+
PATH
|
2
|
+
remote: ../..
|
3
|
+
specs:
|
4
|
+
akasha (0.2.0)
|
5
|
+
corefines (~> 1.11)
|
6
|
+
faraday (~> 0.15)
|
7
|
+
faraday_middleware
|
8
|
+
rack (~> 2.0)
|
9
|
+
retries (~> 0.0)
|
10
|
+
typhoeus (~> 1.3)
|
11
|
+
|
1
12
|
GEM
|
2
13
|
remote: https://rubygems.org/
|
3
14
|
specs:
|
4
|
-
|
15
|
+
corefines (1.11.0)
|
16
|
+
ethon (0.11.0)
|
17
|
+
ffi (>= 1.3.0)
|
18
|
+
faraday (0.15.2)
|
19
|
+
multipart-post (>= 1.2, < 3)
|
20
|
+
faraday_middleware (0.12.2)
|
21
|
+
faraday (>= 0.7.4, < 1.0)
|
22
|
+
ffi (1.9.25)
|
23
|
+
multipart-post (2.0.0)
|
5
24
|
mustermann (1.0.2)
|
6
25
|
rack (2.0.5)
|
7
|
-
rack-protection (2.0.
|
26
|
+
rack-protection (2.0.2)
|
8
27
|
rack
|
9
|
-
|
28
|
+
retries (0.0.5)
|
29
|
+
sinatra (2.0.2)
|
10
30
|
mustermann (~> 1.0)
|
11
31
|
rack (~> 2.0)
|
12
|
-
rack-protection (= 2.0.
|
32
|
+
rack-protection (= 2.0.2)
|
13
33
|
tilt (~> 2.0)
|
14
34
|
tilt (2.0.8)
|
35
|
+
typhoeus (1.3.0)
|
36
|
+
ethon (>= 0.9.0)
|
15
37
|
|
16
38
|
PLATFORMS
|
17
39
|
ruby
|
18
40
|
|
19
41
|
DEPENDENCIES
|
20
|
-
akasha
|
21
|
-
sinatra
|
42
|
+
akasha!
|
43
|
+
sinatra (~> 2, > 2.0.1)
|
22
44
|
|
23
45
|
BUNDLED WITH
|
24
46
|
1.16.2
|
data/examples/sinatra/app.rb
CHANGED
@@ -1,6 +1,8 @@
|
|
1
1
|
require 'akasha'
|
2
2
|
require 'sinatra'
|
3
|
+
require 'singleton'
|
3
4
|
|
5
|
+
# An example aggregate.
|
4
6
|
class User < Akasha::Aggregate
|
5
7
|
attr_reader :email
|
6
8
|
|
@@ -15,6 +17,14 @@ class User < Akasha::Aggregate
|
|
15
17
|
end
|
16
18
|
end
|
17
19
|
|
20
|
+
# An example materializer.
|
21
|
+
class UserListMaterializer < Akasha::EventListener
|
22
|
+
def on_user_signed_up(user_id, **)
|
23
|
+
# Update database.
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# An example event listener; will be used asynchronously.
|
18
28
|
class Notifier < Akasha::EventListener
|
19
29
|
def on_user_signed_up(user_id, **)
|
20
30
|
notify_about_signup(user_id)
|
@@ -26,42 +36,63 @@ class Notifier < Akasha::EventListener
|
|
26
36
|
# Here we could just grab email from the event but let's demonstrate
|
27
37
|
# how to load an aggregate from events.
|
28
38
|
user = User.find_or_create(user_id)
|
29
|
-
email = <<~
|
39
|
+
email = <<~EMAIL
|
30
40
|
User #{user.email} just signed up!
|
31
|
-
|
41
|
+
EMAIL
|
32
42
|
# Let's not send any emails... :)
|
33
43
|
puts email
|
34
44
|
end
|
35
45
|
end
|
36
46
|
|
37
|
-
|
38
|
-
|
47
|
+
# Example Akasha application.
|
48
|
+
class MyAkashaApp
|
49
|
+
include Singleton
|
39
50
|
|
40
|
-
|
41
|
-
repository = Akasha::Repository.new(Akasha::Storage::MemoryEventStore.new)
|
42
|
-
Akasha::Aggregate.connect!(repository)
|
51
|
+
attr_accessor :command_router
|
43
52
|
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
53
|
+
def initialize # rubocop:disable Metrics/MethodLength
|
54
|
+
# Aggregates will load from and save to in-memory storage.
|
55
|
+
repository = Akasha::Repository.new(
|
56
|
+
Akasha::Storage::HttpEventStore.new(
|
57
|
+
username: 'admin',
|
58
|
+
password: 'changeit'
|
59
|
+
),
|
60
|
+
namespace: :my_app
|
61
|
+
)
|
62
|
+
Akasha::Aggregate.connect!(repository)
|
48
63
|
|
49
|
-
|
50
|
-
|
64
|
+
# Set up event listeners.
|
65
|
+
event_router = Akasha::EventRouter.new(
|
66
|
+
user_signed_up: UserListMaterializer
|
67
|
+
)
|
68
|
+
event_router.connect!(repository)
|
69
|
+
|
70
|
+
async_event_router = Akasha::AsyncEventRouter.new(
|
71
|
+
user_signed_up: Notifier
|
72
|
+
)
|
73
|
+
async_event_router.connect!(repository) # Returns Thread instance.
|
74
|
+
|
75
|
+
@command_router = Akasha::CommandRouter.new(
|
76
|
+
sign_up: User,
|
77
|
+
sign_up_admin: lambda { |aggregate_id, **data|
|
78
|
+
user = User.find_or_create(aggregate_id)
|
79
|
+
user.sign_up(email: data[:email], password: data[:password], admin: true)
|
80
|
+
user.save!
|
81
|
+
}
|
82
|
+
)
|
83
|
+
end
|
84
|
+
end
|
51
85
|
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
user = User.find_or_create(aggregate_id)
|
56
|
-
user.sign_up(email: data[:email], password: data[:password], admin: true)
|
57
|
-
user.save!
|
86
|
+
helpers do
|
87
|
+
def route_command(*args)
|
88
|
+
MyAkashaApp.instance.command_router.route!(*args)
|
58
89
|
end
|
59
90
|
end
|
60
91
|
|
61
92
|
post '/users/:user_id' do # With CQRS client pass unique aggregate ids.
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
93
|
+
route_command(:sign_up,
|
94
|
+
params[:user_id],
|
95
|
+
email: params[:email],
|
96
|
+
password: params[:password])
|
66
97
|
'OK'
|
67
98
|
end
|
data/lib/akasha.rb
CHANGED
@@ -3,6 +3,8 @@ require 'akasha/aggregate'
|
|
3
3
|
require 'akasha/command_router'
|
4
4
|
require 'akasha/event_listener'
|
5
5
|
require 'akasha/event_router'
|
6
|
+
require 'akasha/async_event_router'
|
6
7
|
require 'akasha/repository'
|
7
8
|
require 'akasha/storage/http_event_store'
|
8
9
|
require 'akasha/storage/memory_event_store'
|
10
|
+
require 'akasha/checkpoint/http_event_store_checkpoint'
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require_relative 'event_router_base'
|
2
|
+
require_relative 'checkpoint/http_event_store_checkpoint'
|
3
|
+
|
4
|
+
module Akasha
|
5
|
+
# Event router working that can run in the background, providing eventual
|
6
|
+
# consistency. Can use the same EventListeners as the synchronous EventRouter.
|
7
|
+
class AsyncEventRouter < EventRouterBase
|
8
|
+
DEFAULT_POLL_SECONDS = 2
|
9
|
+
DEFAULT_PAGE_SIZE = 20
|
10
|
+
DEFAULT_PROJECTION_STREAM = 'AsyncEventRouter'.freeze
|
11
|
+
DEFAULT_CHECKPOINT_STRATEGY = Akasha::Checkpoint::HttpEventStoreCheckpoint
|
12
|
+
|
13
|
+
def connect!(repository, projection_name: DEFAULT_PROJECTION_STREAM,
|
14
|
+
checkpoint_strategy: DEFAULT_CHECKPOINT_STRATEGY,
|
15
|
+
page_size: DEFAULT_PAGE_SIZE, poll: DEFAULT_POLL_SECONDS)
|
16
|
+
projection_stream = repository.store.streams[projection_name]
|
17
|
+
checkpoint = checkpoint_strategy.is_a?(Class) ? checkpoint_strategy.new(projection_stream) : checkpoint_strategy
|
18
|
+
repository.merge_all_by_event(into: projection_name, only: registered_event_names)
|
19
|
+
Thread.new do
|
20
|
+
run_forever(projection_stream, checkpoint, page_size, poll)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
# TODO: Make it stoppable.
|
27
|
+
def run_forever(projection_stream, checkpoint, page_size, poll)
|
28
|
+
position = checkpoint.latest
|
29
|
+
loop do
|
30
|
+
projection_stream.read_events(position, page_size, poll) do |events|
|
31
|
+
begin
|
32
|
+
events.each do |event|
|
33
|
+
route(event.name, event.metadata[:aggregate_id], **event.data)
|
34
|
+
position = checkpoint.ack(position)
|
35
|
+
end
|
36
|
+
rescue RuntimeError => e
|
37
|
+
puts e # TODO: Decide on a strategy.
|
38
|
+
position = checkpoint.ack(position)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
data/lib/akasha/changeset.rb
CHANGED
@@ -11,7 +11,9 @@ module Akasha
|
|
11
11
|
|
12
12
|
# Adds an event to the changeset.
|
13
13
|
def append(event_name, **data)
|
14
|
-
|
14
|
+
id = SecureRandom.uuid
|
15
|
+
event = Akasha::Event.new(event_name, id, { aggregate_id: @aggregate_id }, **data)
|
16
|
+
@events << event
|
15
17
|
end
|
16
18
|
|
17
19
|
# Returns true if no changes recorded.
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module Akasha
|
2
|
+
module Checkpoint
|
3
|
+
# Stores stream position via HTTP Eventstore API.
|
4
|
+
class HttpEventStoreCheckpoint
|
5
|
+
Error = Class.new(RuntimeError)
|
6
|
+
StreamNotFoundError = Class.new(Error)
|
7
|
+
|
8
|
+
# Creates a new checkpoint, storing position in `stream` every `interval` events.
|
9
|
+
# Use `interval` greater than zero for idempotent event listeners.
|
10
|
+
def initialize(stream, interval: 1)
|
11
|
+
@stream = stream
|
12
|
+
@interval = interval
|
13
|
+
return if @stream.respond_to?(:metadata) && @stream.respond_to?(:metadata=)
|
14
|
+
raise UnsupportedStorageError, "Storage does not support checkpoints: #{stream.class}"
|
15
|
+
end
|
16
|
+
|
17
|
+
# Returns the most recently stored next position.
|
18
|
+
def latest
|
19
|
+
@next_position ||= (read_position || 0)
|
20
|
+
end
|
21
|
+
|
22
|
+
# Returns the next position, conditionally storing it (based on the configurable interval).
|
23
|
+
def ack(position)
|
24
|
+
@next_position = position + 1
|
25
|
+
if (@next_position % @interval).zero?
|
26
|
+
# TODO: Race condition; use optimistic cocurrency.
|
27
|
+
@stream.metadata = @stream.metadata.merge(next_position: @next_position)
|
28
|
+
end
|
29
|
+
@next_position
|
30
|
+
rescue Akasha::Storage::HttpEventStore::HttpClientError => e
|
31
|
+
raise if e.status_code != 404
|
32
|
+
raise StreamNotFoundError, "Stream cannot be checkpointed; it does not exist: #{@stream.name}"
|
33
|
+
end
|
34
|
+
|
35
|
+
protected
|
36
|
+
|
37
|
+
def read_position
|
38
|
+
@stream.metadata[:next_position]
|
39
|
+
rescue Akasha::Storage::HttpEventStore::HttpClientError => e
|
40
|
+
return 0 if e.status_code == 404
|
41
|
+
raise
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -1,13 +1,22 @@
|
|
1
1
|
require_relative 'command_router/default_handler'
|
2
|
+
require 'corefines/hash'
|
2
3
|
|
3
4
|
module Akasha
|
4
5
|
# Routes commands to their handlers.
|
5
6
|
class CommandRouter
|
7
|
+
using Corefines::Hash
|
8
|
+
|
6
9
|
# Raised when no corresponding target can be found for a command.
|
7
10
|
NotFoundError = Class.new(RuntimeError)
|
8
11
|
|
9
|
-
def initialize
|
10
|
-
@routes =
|
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
|
11
20
|
end
|
12
21
|
|
13
22
|
# Registers a custom route, specifying either a lambda or a block.
|
data/lib/akasha/event.rb
CHANGED
@@ -1,28 +1,24 @@
|
|
1
|
+
require 'securerandom'
|
1
2
|
require 'time'
|
2
3
|
|
3
4
|
module Akasha
|
4
|
-
#
|
5
|
-
# event recorded by the system.
|
5
|
+
# Describes a single event recorded by the system.
|
6
6
|
class Event
|
7
|
-
attr_reader :name, :data, :
|
7
|
+
attr_reader :id, :name, :data, :metadata
|
8
8
|
|
9
|
-
def initialize(name,
|
9
|
+
def initialize(name, id = nil, metadata = {}, **data)
|
10
|
+
@id = id || SecureRandom.uuid.to_s # TODO: Use something better.
|
10
11
|
@name = name
|
11
|
-
@
|
12
|
+
@metadata = metadata || { created_at: Time.now.utc }
|
12
13
|
@data = data
|
13
14
|
end
|
14
15
|
|
15
|
-
def metadata
|
16
|
-
{
|
17
|
-
created_at: @created_at
|
18
|
-
}
|
19
|
-
end
|
20
|
-
|
21
16
|
def ==(other)
|
22
17
|
self.class == other.class &&
|
18
|
+
id == other.id &&
|
23
19
|
name == other.name &&
|
24
20
|
data == other.data &&
|
25
|
-
|
21
|
+
metadata == other.metadata
|
26
22
|
end
|
27
23
|
end
|
28
24
|
end
|
data/lib/akasha/event_router.rb
CHANGED
@@ -1,38 +1,15 @@
|
|
1
|
-
|
2
|
-
# Routes events to event listeners.
|
3
|
-
class EventRouter
|
4
|
-
def initialize
|
5
|
-
@routes = Hash.new { |hash, key| hash[key] = [] }
|
6
|
-
end
|
1
|
+
require_relative 'event_router_base'
|
7
2
|
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
listener = listener_class.new
|
18
|
-
begin
|
19
|
-
listener.public_send(:"on_#{event_name}", aggregate_id, **data)
|
20
|
-
rescue RuntimeError => e
|
21
|
-
log "Error handling event #{event_name.inspect}: #{e}"
|
22
|
-
end
|
3
|
+
module Akasha
|
4
|
+
# Routes events synchronously, providing consistency.
|
5
|
+
# Useful for routing to materializers, providing read-your-writes
|
6
|
+
# guarantee.
|
7
|
+
class EventRouter < EventRouterBase
|
8
|
+
# Connects to the repository.
|
9
|
+
def connect!(repository)
|
10
|
+
repository.subscribe do |aggregate_id, event|
|
11
|
+
route(event.name, aggregate_id, **event.data)
|
23
12
|
end
|
24
13
|
end
|
25
|
-
|
26
|
-
# Routes an event (an Akasha::Event instance).
|
27
|
-
# This is interface allowing subscription via `Akasha::Repository#subscribe`.
|
28
|
-
def call(aggregate_id, event)
|
29
|
-
route(event.name, aggregate_id, **event.data)
|
30
|
-
end
|
31
|
-
|
32
|
-
private
|
33
|
-
|
34
|
-
def log(msg)
|
35
|
-
puts msg
|
36
|
-
end
|
37
14
|
end
|
38
15
|
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module Akasha
|
2
|
+
# Base class for routing events to event listeners.
|
3
|
+
class EventRouterBase
|
4
|
+
def initialize(routes = {})
|
5
|
+
@routes = Hash.new { |hash, key| hash[key] = [] }
|
6
|
+
@routes.merge!(routes)
|
7
|
+
end
|
8
|
+
|
9
|
+
# Registers a new event listener, derived from
|
10
|
+
# `Akasha::EventListener`.
|
11
|
+
def register_event_listener(event_name, listener)
|
12
|
+
@routes[event_name] << listener
|
13
|
+
end
|
14
|
+
|
15
|
+
# Routes an event.
|
16
|
+
def route(event_name, aggregate_id, **data)
|
17
|
+
@routes[event_name].each do |listener|
|
18
|
+
listener = listener.new if listener.is_a?(Class)
|
19
|
+
begin
|
20
|
+
listener.public_send(:"on_#{event_name}", aggregate_id, **data)
|
21
|
+
rescue RuntimeError => e
|
22
|
+
log "Error handling event #{event_name.inspect}: #{e}"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
protected
|
28
|
+
|
29
|
+
def registered_event_names
|
30
|
+
@routes.keys
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def log(msg)
|
36
|
+
puts msg
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|