akasha 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -3,6 +3,8 @@ module Akasha
3
3
  # Not meant to be used directly (see aggregate/syntax_helpers.rb)
4
4
  # See specs for usage.
5
5
  class Repository
6
+ attr_reader :store
7
+
6
8
  STREAM_NAME_SEP = '-'.freeze
7
9
 
8
10
  # Creates a new repository using the underlying `store` (e.g. `MemoryEventStore`).
@@ -44,6 +46,17 @@ module Akasha
44
46
  @subscribers << callable
45
47
  end
46
48
 
49
+ # Merges all streams into one, filtering the resulting stream
50
+ # so it only contains events with the specified names, using
51
+ # a projection.
52
+ #
53
+ # Arguments:
54
+ # `into` - name of the new stream
55
+ # `only` - array of event names
56
+ def merge_all_by_event(into:, only:)
57
+ @store.merge_all_by_event(into: into, only: only)
58
+ end
59
+
47
60
  private
48
61
 
49
62
  def stream_name(aggregate_klass, aggregate_id)
@@ -1,24 +1,58 @@
1
+ require_relative 'http_event_store/client'
1
2
  require_relative 'http_event_store/stream'
2
- require 'http_event_store'
3
3
 
4
4
  module Akasha
5
5
  module Storage
6
6
  # HTTP-based interface to Eventstore (https://geteventstore.com)
7
7
  class HttpEventStore
8
- def initialize(host: 'localhost', port: 2113)
9
- @client = ::HttpEventStore::Connection.new do |config|
10
- config.endpoint = host
11
- config.port = port
8
+ # Base class for all HTTP Event store errors.
9
+ Error = Class.new(RuntimeError)
10
+ # Stream name contains invalid characters.
11
+ InvalidStreamNameError = Class.new(Error)
12
+
13
+ # Base class for HTTP errors.
14
+ class HttpError < Error
15
+ attr_reader :status_code
16
+
17
+ def initialize(status_code)
18
+ @status_code = status_code
19
+ super("Unexpected HTTP response: #{@status_code}")
12
20
  end
13
21
  end
14
22
 
23
+ # 4xx HTTP status code.
24
+ HttpClientError = Class.new(HttpError)
25
+ # 5xx HTTP status code.
26
+ HttpServerError = Class.new(HttpError)
27
+
28
+ # Creates a new event store client, connecting to the specified host and port
29
+ # using an optional username and password.
30
+ def initialize(host: 'localhost', port: 2113, username: nil, password: nil)
31
+ @client = Client.new(host: host, port: port, username: username, password: password)
32
+ end
33
+
34
+ # Returns a Hash of streams. You can retrieve a Stream instance corresponding
35
+ # to any stream by its name. The stream does not have to exist, appending to
36
+ # it will create it.
15
37
  def streams
16
38
  self # Use the `[]` method on self.
17
39
  end
18
40
 
41
+ # Shortcut for accessing streams by their names.
19
42
  def [](stream_name)
20
43
  Stream.new(@client, stream_name)
21
44
  end
45
+
46
+ # Merges all streams into one, filtering the resulting stream
47
+ # so it only contains events with the specified names, using
48
+ # a projection.
49
+ #
50
+ # Arguments:
51
+ # `into` - name of the new stream
52
+ # `only` - array of event names
53
+ def merge_all_by_event(into:, only:)
54
+ @client.merge_all_by_event(into, only)
55
+ end
22
56
  end
23
57
  end
24
58
  end
@@ -0,0 +1,169 @@
1
+ require 'base64'
2
+ require 'corefines/hash'
3
+ require 'json'
4
+ require 'faraday'
5
+ require 'faraday_middleware'
6
+ require 'rack/utils'
7
+ require 'retries'
8
+ require 'time'
9
+ require 'typhoeus/adapters/faraday'
10
+
11
+ require_relative 'event_serializer'
12
+ require_relative 'response_handler'
13
+ require_relative 'projection_manager'
14
+
15
+ module Akasha
16
+ module Storage
17
+ class HttpEventStore
18
+ # Eventstore HTTP client.
19
+ class Client
20
+ using Corefines::Hash
21
+
22
+ # A lower limit for a retry interval.
23
+ MIN_RETRY_INTERVAL = 0
24
+ # An upper limit for a retry interval.
25
+ MAX_RETRY_INTERVAL = 10.0
26
+
27
+ # Creates a new client for the host and port with optional username and password
28
+ # for authenticating certain requests.
29
+ def initialize(host: 'localhost', port: 2113, username: nil, password: nil)
30
+ @username = username
31
+ @password = password
32
+ @conn = connection(host, port)
33
+ @serializer = EventSerializer.new
34
+ end
35
+
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)
38
+ retrying_on_network_failures(max_retries) do
39
+ append_to_stream(stream_name, events, expected_version)
40
+ end
41
+ end
42
+
43
+ # Read events from stream, retrying_on_network_failures up to `max_retries` in case of network failures.
44
+ # Reads `count` events starting from `start` inclusive.
45
+ # Can long-poll for events if `poll` is specified.`
46
+ def retry_read_events_forward(stream_name, start, count, poll = 0, max_retries: 0)
47
+ retrying_on_network_failures(max_retries) do
48
+ safe_read_events(stream_name, start, count, poll)
49
+ end
50
+ end
51
+
52
+ # Merges all streams into one, filtering the resulting stream
53
+ # so it only contains events with the specified names, using
54
+ # a projection.
55
+ #
56
+ # Arguments:
57
+ # `name` - name of the projection stream
58
+ # `event_names` - array of event names
59
+ def merge_all_by_event(name, event_names, max_retries: 0)
60
+ retrying_on_network_failures(max_retries) do
61
+ ProjectionManager.new(self).merge_all_by_event(name, event_names)
62
+ end
63
+ end
64
+
65
+ # Reads stream metadata.
66
+ def retry_read_metadata(stream_name, max_retries: 0)
67
+ retrying_on_network_failures(max_retries) do
68
+ safe_read_metadata(stream_name)
69
+ end
70
+ end
71
+
72
+ # Updates stream metadata.
73
+ def retry_write_metadata(stream_name, metadata)
74
+ event = Akasha::Event.new(:stream_metadata_changed, SecureRandom.uuid, metadata)
75
+ retry_append_to_stream("#{stream_name}/metadata", [event])
76
+ end
77
+
78
+ # Issues a generic request against the API.
79
+ def request(method, path, body = nil, headers = {})
80
+ body = @conn.public_send(method, path, body, auth_headers.merge(headers)).body
81
+ return {} if body.empty?
82
+ body
83
+ end
84
+
85
+ private
86
+
87
+ def connection(host, port)
88
+ Faraday.new do |conn|
89
+ conn.host = host
90
+ conn.port = port
91
+ conn.response :json, content_type: 'application/json'
92
+ conn.use ResponseHandler
93
+ conn.adapter :typhoeus
94
+ end
95
+ end
96
+
97
+ def auth_headers
98
+ if @username && @password
99
+ auth = Base64.urlsafe_encode64([@username, @password].join(':'))
100
+ {
101
+ 'Authorization' => "Basic #{auth}"
102
+ }
103
+ else
104
+ {}
105
+ end
106
+ end
107
+
108
+ def retrying_on_network_failures(max_retries)
109
+ with_retries(base_sleep_seconds: MIN_RETRY_INTERVAL,
110
+ max_sleep_seconds: MAX_RETRY_INTERVAL,
111
+ max_tries: 1 + max_retries,
112
+ rescue: [Faraday::TimeoutError, Faraday::ConnectionFailed]) do
113
+ yield
114
+ end
115
+ end
116
+
117
+ def append_to_stream(stream_name, events, _expected_version = nil)
118
+ @conn.post("/streams/#{stream_name}") do |req|
119
+ req.headers = {
120
+ 'Content-Type' => 'application/vnd.eventstore.events+json',
121
+ # 'ES-ExpectedVersion' => expected_version
122
+ }
123
+ req.body = to_event_data(events).to_json
124
+ end
125
+ end
126
+
127
+ def safe_read_events(stream_name, start, count, poll)
128
+ resp = @conn.get("/streams/#{stream_name}/#{start}/forward/#{count}") do |req|
129
+ req.headers = {
130
+ 'Accept' => 'application/json'
131
+ }
132
+ req.headers['ES-LongPoll'] = poll if poll&.positive?
133
+ req.params['embed'] = 'body'
134
+ end
135
+ event_data = resp.body['entries']
136
+ to_events(event_data)
137
+ rescue HttpClientError => e
138
+ return [] if e.status_code == 404
139
+ raise
140
+ rescue URI::InvalidURIError
141
+ raise InvalidStreamNameError, "Invalid stream name: #{stream_name}"
142
+ end
143
+
144
+ def safe_read_metadata(stream_name)
145
+ metadata = request(:get, "/streams/#{stream_name}/metadata", nil, 'Accept' => 'application/json')
146
+ metadata.symbolize_keys
147
+ rescue HttpClientError => e
148
+ return {} if e.status_code == 404
149
+ raise
150
+ rescue URI::InvalidURIError
151
+ raise InvalidStreamNameError, "Invalid stream name: #{stream_name}"
152
+ end
153
+
154
+ def to_event_data(events)
155
+ @serializer.serialize(events)
156
+ end
157
+
158
+ def to_events(es_events)
159
+ es_events = es_events.map do |ev|
160
+ ev['data'] &&= JSON.parse(ev['data'])
161
+ ev['metaData'] &&= JSON.parse(ev['metaData'])
162
+ ev
163
+ end
164
+ @serializer.deserialize(es_events)
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,34 @@
1
+ require 'corefines/hash'
2
+
3
+ module Akasha
4
+ module Storage
5
+ class HttpEventStore
6
+ # Serializes and deserializes events to and from the format required
7
+ # by the HTTP Eventstore API
8
+ class EventSerializer
9
+ using Corefines::Hash
10
+
11
+ def serialize(events)
12
+ events.map do |event|
13
+ base = {
14
+ 'eventType' => event.name,
15
+ 'data' => event.data,
16
+ 'metaData' => event.metadata
17
+ }
18
+ base['eventId'] = event.id unless event.id.nil?
19
+ base
20
+ end
21
+ end
22
+
23
+ def deserialize(es_events)
24
+ es_events.map do |ev|
25
+ metadata = ev['metaData']&.symbolize_keys || {}
26
+ data = ev['data']&.symbolize_keys || {}
27
+ event = Akasha::Event.new(ev['eventType'].to_sym, ev['eventId'], metadata, **data)
28
+ event
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,67 @@
1
+ module Akasha
2
+ module Storage
3
+ class HttpEventStore
4
+ # Manages HTTP ES projections.
5
+ class ProjectionManager
6
+ def initialize(client)
7
+ @client = client
8
+ end
9
+
10
+ # Merges all streams into one, filtering the resulting stream
11
+ # so it only contains events with the specified names, using
12
+ # a projection.
13
+ #
14
+ # Arguments:
15
+ # `name` - name of the projection stream
16
+ # `event_names` - array of event names
17
+ def merge_all_by_event(name, event_names)
18
+ attempt_create_projection(name, event_names) ||
19
+ update_projection(name, event_names)
20
+ end
21
+
22
+ private
23
+
24
+ def projection_javascript(name, events)
25
+ callbacks = events.map { |en| "\"#{en}\": function(s,e) { linkTo('#{name}', e) }" }
26
+ # Alternative code using internal indexing.
27
+ # It's broken though because it reorders events for aggregates (because the streams
28
+ # it uses are per-event). An alternative would be to use aggregates as streams
29
+ # to pull from.
30
+ # et_streams = events.map { |en| "\"$et-#{en}\"" }
31
+ # "fromStreams([#{et_streams.join(', ')}]).when({ #{callbacks.join(', ')} });"
32
+ ''"
33
+ // This is hard to find, so I'm leaving it here:
34
+ // options({
35
+ // reorderEvents: true,
36
+ // processingLag: 100 //time in ms
37
+ // });
38
+ fromAll().when({ #{callbacks.join(', ')} });
39
+ "''
40
+ end
41
+
42
+ def attempt_create_projection(name, event_names)
43
+ create_options = {
44
+ name: name,
45
+ emit: :yes,
46
+ checkpoints: :yes,
47
+ enabled: :yes
48
+ }
49
+ query_string = Rack::Utils.build_query(create_options)
50
+ @client.request(:post, "/projections/continuous?#{query_string}",
51
+ projection_javascript(name, event_names),
52
+ 'Content-Type' => 'application/javascript')
53
+ true
54
+ rescue HttpClientError => e
55
+ return false if e.status_code == 409
56
+ raise
57
+ end
58
+
59
+ def update_projection(name, event_names)
60
+ @client.request(:put, "/projection/#{name}/query?emit=yet",
61
+ projection_javascript(name, event_names),
62
+ 'Content-Type' => 'application/javascript')
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,17 @@
1
+ module Akasha
2
+ module Storage
3
+ class HttpEventStore
4
+ # Handles responses from Eventstore HTTP API.
5
+ class ResponseHandler < Faraday::Response::Middleware
6
+ def on_complete(env)
7
+ case env[:status]
8
+ when (400..499)
9
+ raise HttpClientError, env.status
10
+ when (500..599)
11
+ raise HttpServerError, env.status
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -3,46 +3,46 @@ module Akasha
3
3
  class HttpEventStore
4
4
  # HTTP Eventstore stream.
5
5
  class Stream
6
+ attr_reader :name
7
+
6
8
  def initialize(client, stream_name)
7
9
  @client = client
8
- @stream_name = stream_name
10
+ @name = stream_name
9
11
  end
10
12
 
11
13
  # Appends events to the stream.
12
14
  def write_events(events)
13
- event_hashes = events.map do |event|
14
- {
15
- event_type: event.name,
16
- data: event.data,
17
- metadata: event.metadata
18
- }
19
- end
20
- @client.append_to_stream(@stream_name, event_hashes)
15
+ return if events.empty?
16
+ @client.retry_append_to_stream(@name, events)
21
17
  end
22
18
 
23
19
  # Reads events from the stream starting from `start` inclusive.
24
20
  # If block given, reads all events from the position in pages of `page_size`.
25
21
  # If block not given, reads `size` events from the position.
26
- def read_events(start, page_size)
22
+ # You can also turn on long-polling using `poll` and setting it to the number
23
+ # of seconds to wait for.
24
+ def read_events(start, page_size, poll = 0)
27
25
  if block_given?
28
26
  position = start
29
27
  loop do
30
- events = read_events(position, page_size)
28
+ events = read_events(position, page_size, poll)
31
29
  return if events.empty?
32
30
  yield(events)
33
31
  position += events.size
34
32
  end
35
33
  else
36
- safe_read_events(start, page_size)
34
+ @client.retry_read_events_forward(@name, start, page_size, poll)
37
35
  end
38
36
  end
39
37
 
40
- private
38
+ # Reads stream metadata.
39
+ def metadata
40
+ @client.retry_read_metadata(@name)
41
+ end
41
42
 
42
- def safe_read_events(start, page_size)
43
- @client.read_events_forward(@stream_name, start, page_size)
44
- rescue ::HttpEventStore::StreamNotFound
45
- []
43
+ # Updates stream metadata.
44
+ def metadata=(metadata)
45
+ @client.retry_write_metadata(@name, metadata)
46
46
  end
47
47
  end
48
48
  end
@@ -10,7 +10,37 @@ module Akasha
10
10
  attr_reader :streams
11
11
 
12
12
  def initialize
13
- @streams = Hash.new { |streams, name| streams[name] = Stream.new }
13
+ store = self
14
+ @streams = Hash.new do |streams, name|
15
+ streams[name] = Stream.new do |new_events|
16
+ store.update_projections(new_events)
17
+ new_events
18
+ end
19
+ end
20
+ @projections = []
21
+ end
22
+
23
+ # Merges all streams into one, filtering the resulting stream
24
+ # so it only contains events with the specified names.
25
+ #
26
+ # Arguments:
27
+ # `new_stream_name` - name of the new stream
28
+ # `only` - array of event names
29
+ def merge_all_by_event(into:, only:)
30
+ new_stream = Stream.new do |new_events|
31
+ new_events.select { |event| only.include?(event.name) }
32
+ end
33
+ @streams[into] = new_stream
34
+ @projections << new_stream
35
+ new_stream
36
+ end
37
+
38
+ protected
39
+
40
+ def update_projections(events)
41
+ @projections.each do |projection|
42
+ projection.write_events(events)
43
+ end
14
44
  end
15
45
  end
16
46
  end