sourced 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,3 @@
1
+ --color
2
+ --require spec_helper
3
+ --format documentation
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2024-09-27
4
+
5
+ - Initial release
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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
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)
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './cart'
4
+
5
+ Sourced::Supervisor.start
@@ -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