sourced 0.0.1
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 +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
|