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.
@@ -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
@@ -1,4 +1,4 @@
1
- source "https://rubygems.org"
1
+ source 'https://rubygems.org'
2
2
 
3
- gem 'sinatra'
4
- gem 'akasha'
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
- akasha (0.1.0)
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.1)
26
+ rack-protection (2.0.2)
8
27
  rack
9
- sinatra (2.0.1)
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.1)
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
@@ -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 = <<~EOS
39
+ email = <<~EMAIL
30
40
  User #{user.email} just signed up!
31
- EOS
41
+ EMAIL
32
42
  # Let's not send any emails... :)
33
43
  puts email
34
44
  end
35
45
  end
36
46
 
37
- before do
38
- @command_router = Akasha::CommandRouter.new
47
+ # Example Akasha application.
48
+ class MyAkashaApp
49
+ include Singleton
39
50
 
40
- # Aggregates will load from and save to in-memory storage.
41
- repository = Akasha::Repository.new(Akasha::Storage::MemoryEventStore.new)
42
- Akasha::Aggregate.connect!(repository)
51
+ attr_accessor :command_router
43
52
 
44
- # Set up event listeners.
45
- @event_router = Akasha::EventRouter.new
46
- @event_router.register_event_listener(:user_signed_up, Notifier)
47
- repository.subscribe(@event_router)
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
- # This is how you link commands to aggregates.
50
- @command_router.register_default_route(:sign_up, User)
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
- # Nearly identital to the default handling above but we're setting the admin
53
- # flag to demo custom command handling.
54
- @command_router.register_route(:sign_up_admin) do |aggregate_id, **data|
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
- @command_router.route!(:sign_up,
63
- params[:user_id],
64
- email: params[:email],
65
- password: params[:password])
93
+ route_command(:sign_up,
94
+ params[:user_id],
95
+ email: params[:email],
96
+ password: params[:password])
66
97
  'OK'
67
98
  end
@@ -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'
@@ -20,7 +20,7 @@ module Akasha
20
20
  module ClassMethods
21
21
  # Connects to a repository.
22
22
  def connect!(repository)
23
- @@repository = repository
23
+ @@repository = repository # rubocop:disable Style/ClassVars
24
24
  end
25
25
 
26
26
  # Returns repository or nil if `connect!` not called.
@@ -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
@@ -11,7 +11,9 @@ module Akasha
11
11
 
12
12
  # Adds an event to the changeset.
13
13
  def append(event_name, **data)
14
- @events << Akasha::Event.new(event_name, **data)
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.
@@ -1,28 +1,24 @@
1
+ require 'securerandom'
1
2
  require 'time'
2
3
 
3
4
  module Akasha
4
- # Event contains all information pertaining to a single
5
- # event recorded by the system.
5
+ # Describes a single event recorded by the system.
6
6
  class Event
7
- attr_reader :name, :data, :created_at
7
+ attr_reader :id, :name, :data, :metadata
8
8
 
9
- def initialize(name, created_at = Time.now.utc, **data)
9
+ def initialize(name, id = nil, metadata = {}, **data)
10
+ @id = id || SecureRandom.uuid.to_s # TODO: Use something better.
10
11
  @name = name
11
- @created_at = created_at
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
- created_at == other.created_at
21
+ metadata == other.metadata
26
22
  end
27
23
  end
28
24
  end
@@ -1,38 +1,15 @@
1
- module Akasha
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
- # Registers a new event listener, derived from
9
- # `Akasha::EventListener`.
10
- def register_event_listener(event_name, listener_class)
11
- @routes[event_name] << listener_class
12
- end
13
-
14
- # Routes an event.
15
- def route(event_name, aggregate_id, **data)
16
- @routes[event_name].each do |listener_class|
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