sourced 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +5 -0
- data/README.md +34 -0
- data/Rakefile +8 -0
- data/config.ru +0 -0
- data/examples/cart.rb +229 -0
- data/examples/workers.rb +5 -0
- data/lib/sourced/backends/active_record_backend.rb +184 -0
- data/lib/sourced/backends/sequel_backend.rb +387 -0
- data/lib/sourced/backends/test_backend.rb +273 -0
- data/lib/sourced/command_context.rb +46 -0
- data/lib/sourced/configuration.rb +39 -0
- data/lib/sourced/consumer.rb +42 -0
- data/lib/sourced/decide.rb +50 -0
- data/lib/sourced/decider.rb +251 -0
- data/lib/sourced/evolve.rb +102 -0
- data/lib/sourced/message.rb +202 -0
- data/lib/sourced/projector.rb +131 -0
- data/lib/sourced/rails/install_generator.rb +57 -0
- data/lib/sourced/rails/railtie.rb +16 -0
- data/lib/sourced/rails/templates/bin_sors +8 -0
- data/lib/sourced/rails/templates/create_sors_tables.rb.erb +55 -0
- data/lib/sourced/react.rb +57 -0
- data/lib/sourced/router.rb +148 -0
- data/lib/sourced/supervisor.rb +49 -0
- data/lib/sourced/sync.rb +80 -0
- data/lib/sourced/types.rb +24 -0
- data/lib/sourced/version.rb +5 -0
- data/lib/sourced/worker.rb +93 -0
- data/lib/sourced.rb +42 -0
- data/sig/sors.rbs +4 -0
- metadata +103 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 86885b939ec61102ef829ec6272cbb32e789c91d0be359226e35fd1f70a13aee
|
4
|
+
data.tar.gz: 7ee1faaaf4e58800dd31dbb530e4549771e263084ae69379bf72bce01e784b06
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 628959098ebde04be796875608fa1410bd3bec7d40a6d362d944eb77769134b4fbc38858caa10cdd5ffa06817d693e5e5c8ca1ed89d4f2cbf25465e57af10ebd
|
7
|
+
data.tar.gz: d669516fe37fa9b714d7163b1b2055a1fd3ef91f05b4101c6e469317f6e99842411648017d2f3d0380aef6f9931f2f9cbb57724a47bd638fbb3fe744495ec02f
|
data/.rspec
ADDED
data/CHANGELOG.md
ADDED
data/README.md
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
# sourced
|
2
|
+
|
3
|
+
** WORK IN PROGRESS**
|
4
|
+
|
5
|
+
Event Sourcing / CQRS library for Ruby.
|
6
|
+
There's many ES gems available already. The objectives here are:
|
7
|
+
* Cohesive and toy-like DX.
|
8
|
+
* Eventual consistency by default.
|
9
|
+
* Explore ES as a programming model for Ruby apps.
|
10
|
+
|
11
|
+
## Installation
|
12
|
+
|
13
|
+
Install the gem and add to the application's Gemfile by executing:
|
14
|
+
|
15
|
+
$ bundle add sourced
|
16
|
+
|
17
|
+
**Note**: this gem is under active development, so you probably want to install from Github:
|
18
|
+
In your Gemfile:
|
19
|
+
|
20
|
+
$ gem 'sourced', github: 'ismasan/sourced'
|
21
|
+
|
22
|
+
## Usage
|
23
|
+
|
24
|
+
TODO: Write usage instructions here
|
25
|
+
|
26
|
+
## Development
|
27
|
+
|
28
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
29
|
+
|
30
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
31
|
+
|
32
|
+
## Contributing
|
33
|
+
|
34
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/sourced.
|
data/Rakefile
ADDED
data/config.ru
ADDED
File without changes
|
data/examples/cart.rb
ADDED
@@ -0,0 +1,229 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'bundler'
|
4
|
+
Bundler.setup(:test)
|
5
|
+
|
6
|
+
require 'sourced'
|
7
|
+
require 'sequel'
|
8
|
+
|
9
|
+
# ActiveRecord::Base.establish_connection(adapter: 'postgresql', database: 'decider')
|
10
|
+
unless ENV['backend_configured']
|
11
|
+
puts 'aggregate config'
|
12
|
+
Sourced.configure do |config|
|
13
|
+
config.backend = Sequel.postgres('sourced_development')
|
14
|
+
end
|
15
|
+
ENV['backend_configured'] = 'true'
|
16
|
+
end
|
17
|
+
|
18
|
+
# A cart Actor/Aggregate
|
19
|
+
# Example:
|
20
|
+
# cart = Cart.new('cart-1')
|
21
|
+
# cart.add_item(name: 'item1', price: 100)
|
22
|
+
# cart.place
|
23
|
+
# cart.events
|
24
|
+
#
|
25
|
+
# The above sends a Cart::Place command
|
26
|
+
# which produces a Cart::Placed event
|
27
|
+
class Cart < Sourced::Decider
|
28
|
+
State = Struct.new(:status, :notified, :items, :mailer_id) do
|
29
|
+
def total = items.sum(&:price)
|
30
|
+
end
|
31
|
+
|
32
|
+
def init_state(_id)
|
33
|
+
State.new(:open, false, [], nil)
|
34
|
+
end
|
35
|
+
|
36
|
+
ItemAdded = Sourced::Message.define('cart.item_added') do
|
37
|
+
attribute :name, String
|
38
|
+
attribute :price, Integer
|
39
|
+
end
|
40
|
+
|
41
|
+
Placed = Sourced::Message.define('cart.placed')
|
42
|
+
Notified = Sourced::Message.define('cart.notified') do
|
43
|
+
attribute :mailer_id, String
|
44
|
+
end
|
45
|
+
|
46
|
+
# Defines a Cart::AddItem command struct
|
47
|
+
command :add_item, 'cart.add_item', name: String, price: Integer do |cart, cmd|
|
48
|
+
apply(ItemAdded, cmd.payload.to_h)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Defines a Cart::Place command struct
|
52
|
+
command :place, 'cart.place' do |_, cmd|
|
53
|
+
apply(Placed)
|
54
|
+
end
|
55
|
+
|
56
|
+
# Defines a Cart::Notify command struct
|
57
|
+
command :notify, 'cart.notify', mailer_id: String do |_, cmd|
|
58
|
+
puts "#{self.class.name} #{cmd.stream_id} NOTIFY"
|
59
|
+
apply(Notified, mailer_id: cmd.payload.mailer_id)
|
60
|
+
end
|
61
|
+
|
62
|
+
evolve ItemAdded do |cart, event|
|
63
|
+
cart.items << event.payload
|
64
|
+
end
|
65
|
+
|
66
|
+
evolve Placed do |cart, _event|
|
67
|
+
cart.status = :placed
|
68
|
+
end
|
69
|
+
|
70
|
+
evolve Notified do |cart, event|
|
71
|
+
cart.notified = true
|
72
|
+
cart.mailer_id = event.payload.mailer_id
|
73
|
+
end
|
74
|
+
|
75
|
+
# This block will run
|
76
|
+
# in the same transaction as appending
|
77
|
+
# new events to the store.
|
78
|
+
# So if either fails, eveything is rolled back.
|
79
|
+
# ergo, strong consistency.
|
80
|
+
sync do |command, events|
|
81
|
+
puts "#{self.class.name} #{events.last.seq} SYNC"
|
82
|
+
end
|
83
|
+
|
84
|
+
# Or register a Reactor interface to react to events
|
85
|
+
# synchronously
|
86
|
+
# sync CartListings
|
87
|
+
end
|
88
|
+
|
89
|
+
class Mailer < Sourced::Decider
|
90
|
+
EmailSent = Sourced::Message.define('mailer.email_sent') do
|
91
|
+
attribute :cart_id, String
|
92
|
+
end
|
93
|
+
|
94
|
+
def init_state(_id)
|
95
|
+
[]
|
96
|
+
end
|
97
|
+
|
98
|
+
command :send_email, 'mailer.send_email', cart_id: String do |_, cmd|
|
99
|
+
# Send email here, emit EmailSent if successful
|
100
|
+
apply(EmailSent, cart_id: cmd.payload.cart_id)
|
101
|
+
end
|
102
|
+
|
103
|
+
evolve EmailSent do |list, event|
|
104
|
+
list << event
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
# A Saga that orchestrates the flow between Cart and Mailer
|
109
|
+
class CartEmailsSaga < Sourced::Decider
|
110
|
+
# Listen for Cart::Placed events and
|
111
|
+
# send command to Mailer
|
112
|
+
react Cart::Placed do |event|
|
113
|
+
event.follow_with_stream_id(
|
114
|
+
Mailer::SendEmail,
|
115
|
+
"mailer-#{event.stream_id}",
|
116
|
+
cart_id: event.stream_id
|
117
|
+
)
|
118
|
+
end
|
119
|
+
|
120
|
+
# Listen for Mailer::EmailSent events and
|
121
|
+
# send command to Cart
|
122
|
+
react Mailer::EmailSent do |event|
|
123
|
+
event.follow_with_stream_id(
|
124
|
+
Cart::Notify,
|
125
|
+
event.payload.cart_id,
|
126
|
+
mailer_id: event.stream_id
|
127
|
+
)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
# A projector
|
132
|
+
# "reacts" to events registered with .evolve
|
133
|
+
class CartListings < Sourced::Decider
|
134
|
+
class << self
|
135
|
+
def handled_events = self.handled_events_for_evolve
|
136
|
+
|
137
|
+
# The Reactor interface
|
138
|
+
# @param events [Array<Message>]
|
139
|
+
def handle_events(events)
|
140
|
+
# For this type of event sourced projections
|
141
|
+
# that load current state from events
|
142
|
+
# then apply "new" events
|
143
|
+
# TODO: the current state already includes
|
144
|
+
# the new events, so we need to load upto events.first.seq
|
145
|
+
instance = load(events.first.stream_id, upto: events.first.seq - 1)
|
146
|
+
instance.handle_events(events)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
def handle_events(events)
|
151
|
+
evolve(state, events)
|
152
|
+
save
|
153
|
+
[] # no commands
|
154
|
+
end
|
155
|
+
|
156
|
+
def initialize(id, **_args)
|
157
|
+
super
|
158
|
+
FileUtils.mkdir_p('examples/carts')
|
159
|
+
@path = "./examples/carts/#{id}.json"
|
160
|
+
end
|
161
|
+
|
162
|
+
private def save
|
163
|
+
backend.transaction do
|
164
|
+
run_sync_blocks(state, nil, [])
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
def init_state(id)
|
169
|
+
{ id:, items: [], status: :open, seq: 0, seqs: [] }
|
170
|
+
end
|
171
|
+
|
172
|
+
sync do |cart, _command, _events|
|
173
|
+
File.write(@path, JSON.pretty_generate(cart))
|
174
|
+
end
|
175
|
+
|
176
|
+
# Register all events from Cart
|
177
|
+
# So that before_evolve runs before all cart events
|
178
|
+
evolve_all Cart.handled_commands
|
179
|
+
evolve_all Cart
|
180
|
+
|
181
|
+
before_evolve do |cart, event|
|
182
|
+
cart[:seq] = event.seq
|
183
|
+
cart[:seqs] << event.seq
|
184
|
+
end
|
185
|
+
|
186
|
+
evolve Cart::Placed do |cart, event|
|
187
|
+
cart[:status] = :placed
|
188
|
+
end
|
189
|
+
|
190
|
+
evolve Cart::ItemAdded do |cart, event|
|
191
|
+
cart[:items] << event.payload.to_h
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
class LoggingReactor
|
196
|
+
extend Sourced::Consumer
|
197
|
+
|
198
|
+
class << self
|
199
|
+
# Register as a Reactor that cares about these events
|
200
|
+
# The workers will use this to fetch the right events
|
201
|
+
# and ACK offsets after processing
|
202
|
+
#
|
203
|
+
# @return [Array<Message>]
|
204
|
+
def handled_events = [Cart::Placed, Cart::ItemAdded]
|
205
|
+
|
206
|
+
# Workers pass available events to this method
|
207
|
+
# in order, with exactly-once semantics
|
208
|
+
# If a list of commands is returned,
|
209
|
+
# workers will send them to the router
|
210
|
+
# to be dispatched to the appropriate command handlers.
|
211
|
+
#
|
212
|
+
# @param events [Array<Message>]
|
213
|
+
# @return [Array<Message]
|
214
|
+
def handle_events(events)
|
215
|
+
puts "LoggingReactor received #{events}"
|
216
|
+
[]
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
# Cart.sync CartListings
|
222
|
+
|
223
|
+
# Register Reactor interfaces with the Router
|
224
|
+
# This allows the Router to route commands and events to reactors
|
225
|
+
Sourced::Router.register(LoggingReactor)
|
226
|
+
Sourced::Router.register(Cart)
|
227
|
+
Sourced::Router.register(Mailer)
|
228
|
+
Sourced::Router.register(CartEmailsSaga)
|
229
|
+
Sourced::Router.register(CartListings)
|
data/examples/workers.rb
ADDED
@@ -0,0 +1,184 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_record'
|
4
|
+
require 'json'
|
5
|
+
require 'sourced/message'
|
6
|
+
|
7
|
+
module Sourced
|
8
|
+
module Backends
|
9
|
+
class ActiveRecordBackend
|
10
|
+
PREFIX = 'sourced'
|
11
|
+
|
12
|
+
class EventRecord < ActiveRecord::Base
|
13
|
+
self.inheritance_column = nil
|
14
|
+
self.table_name = [PREFIX, '_events'].join
|
15
|
+
end
|
16
|
+
|
17
|
+
class StreamRecord < ActiveRecord::Base
|
18
|
+
self.table_name = [PREFIX, '_streams'].join
|
19
|
+
end
|
20
|
+
|
21
|
+
class CommandRecord < ActiveRecord::Base
|
22
|
+
self.table_name = [PREFIX, '_commands'].join
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.table_prefix=(prefix)
|
26
|
+
@table_prefix = prefix
|
27
|
+
EventRecord.table_name = [prefix, '_events'].join
|
28
|
+
StreamRecord.table_name = [prefix, '_streams'].join
|
29
|
+
CommandRecord.table_name = [prefix, '_commands'].join
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.table_prefix
|
33
|
+
@table_prefix || PREFIX
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.installed?
|
37
|
+
ActiveRecord::Base.connection.table_exists?(EventRecord.table_name) &&
|
38
|
+
ActiveRecord::Base.connection.table_exists?(StreamRecord.table_name) &&
|
39
|
+
ActiveRecord::Base.connection.table_exists?(CommandRecord.table_name)
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.uninstall!
|
43
|
+
raise 'Not in test environment' unless ENV['ENVIRONMENT'] == 'test'
|
44
|
+
|
45
|
+
ActiveRecord::Base.connection.drop_table(EventRecord.table_name)
|
46
|
+
ActiveRecord::Base.connection.drop_table(CommandRecord.table_name)
|
47
|
+
ActiveRecord::Base.connection.drop_table(StreamRecord.table_name)
|
48
|
+
end
|
49
|
+
|
50
|
+
def initialize
|
51
|
+
@serialize_event = method(:serialize_jsonb_event)
|
52
|
+
@deserialize_event = method(:deserialize_jsonb_event)
|
53
|
+
if EventRecord.connection.class.name == 'ActiveRecord::ConnectionAdapters::SQLite3Adapter'
|
54
|
+
@serialize_event = method(:serialize_sqlite_event)
|
55
|
+
@deserialize_event = method(:deserialize_sqlite_event)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def installed? = self.class.installed?
|
60
|
+
|
61
|
+
def clear!
|
62
|
+
raise 'Not in test environment' unless ENV['ENVIRONMENT'] == 'test'
|
63
|
+
|
64
|
+
EventRecord.delete_all
|
65
|
+
CommandRecord.delete_all
|
66
|
+
StreamRecord.delete_all
|
67
|
+
end
|
68
|
+
|
69
|
+
# TODO: if all commands belong to the same stream_id
|
70
|
+
# we could upsert the streams table once here
|
71
|
+
def schedule_commands(commands)
|
72
|
+
return false if commands.empty?
|
73
|
+
|
74
|
+
# TODO: here we could use multi_insert
|
75
|
+
# for both streams and commands
|
76
|
+
CommandRecord.transaction do
|
77
|
+
commands.each do |command|
|
78
|
+
schedule_command(command.stream_id, command)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
true
|
82
|
+
end
|
83
|
+
|
84
|
+
def schedule_command(stream_id, command)
|
85
|
+
CommandRecord.transaction do
|
86
|
+
StreamRecord.upsert({ stream_id: }, unique_by: :stream_id)
|
87
|
+
CommandRecord.create!(stream_id:, data: command.to_json)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# TODO: locking the stream could be
|
92
|
+
# done in a single SQL query, or using an SQL function.
|
93
|
+
def reserve_next(&)
|
94
|
+
command_record = transaction do
|
95
|
+
cmd = CommandRecord
|
96
|
+
.joins("INNER JOIN #{StreamRecord.table_name} ON #{CommandRecord.table_name}.stream_id = #{StreamRecord.table_name}.stream_id")
|
97
|
+
.where(["#{StreamRecord.table_name}.locked = ?", false])
|
98
|
+
.order("#{CommandRecord.table_name}.id ASC")
|
99
|
+
.lock # "FOR UPDATE"
|
100
|
+
.first
|
101
|
+
|
102
|
+
if cmd
|
103
|
+
StreamRecord.where(stream_id: cmd.stream_id).update(locked: true)
|
104
|
+
end
|
105
|
+
cmd
|
106
|
+
end
|
107
|
+
|
108
|
+
cmd = nil
|
109
|
+
if command_record
|
110
|
+
# TODO: find out why #data isn't already
|
111
|
+
# deserialized here
|
112
|
+
data = JSON.parse(command_record.data, symbolize_names: true)
|
113
|
+
cmd = Message.from(data)
|
114
|
+
yield cmd
|
115
|
+
# Only delete the command if processing didn't raise
|
116
|
+
command_record.destroy
|
117
|
+
end
|
118
|
+
cmd
|
119
|
+
ensure
|
120
|
+
# Always unlock the stream
|
121
|
+
if command_record
|
122
|
+
StreamRecord.where(stream_id: command_record.stream_id).update(locked: false)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def transaction(&)
|
127
|
+
EventRecord.transaction(&)
|
128
|
+
end
|
129
|
+
|
130
|
+
def append_events(events)
|
131
|
+
rows = events.map { |e| serialize_event(e) }
|
132
|
+
EventRecord.insert_all!(rows)
|
133
|
+
true
|
134
|
+
rescue ActiveRecord::RecordNotUnique => e
|
135
|
+
raise Sourced::ConcurrentAppendError, e.message
|
136
|
+
end
|
137
|
+
|
138
|
+
def read_event_batch(causation_id)
|
139
|
+
EventRecord.where(causation_id:).order(:global_seq).map do |record|
|
140
|
+
deserialize_event(record)
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def read_event_stream(stream_id, after: nil, upto: nil)
|
145
|
+
query = EventRecord.where(stream_id:)
|
146
|
+
query = query.where('seq > ?', after) if after
|
147
|
+
query = query.where('seq <= ?', upto) if upto
|
148
|
+
query.order(:global_seq).map do |record|
|
149
|
+
deserialize_event(record)
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
private
|
154
|
+
|
155
|
+
def serialize_event(event)
|
156
|
+
@serialize_event.(event)
|
157
|
+
end
|
158
|
+
|
159
|
+
def serialize_jsonb_event(event)
|
160
|
+
event.to_h
|
161
|
+
end
|
162
|
+
|
163
|
+
def serialize_sqlite_event(event)
|
164
|
+
attrs = event.to_h
|
165
|
+
attrs[:payload] = JSON.generate(attrs[:payload]) if attrs[:payload].present?
|
166
|
+
attrs
|
167
|
+
end
|
168
|
+
|
169
|
+
def deserialize_event(record)
|
170
|
+
Message.from(@deserialize_event.(record).deep_symbolize_keys)
|
171
|
+
end
|
172
|
+
|
173
|
+
def deserialize_jsonb_event(record)
|
174
|
+
record.attributes
|
175
|
+
end
|
176
|
+
|
177
|
+
def deserialize_sqlite_event(record)
|
178
|
+
attrs = record.attributes
|
179
|
+
attrs['payload'] = JSON.parse(attrs['payload']) if attrs['payload'].present?
|
180
|
+
attrs
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|