eventsimple 1.0.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.
- checksums.yaml +7 -0
- data/.DS_Store +0 -0
- data/.rspec +3 -0
- data/.rubocop.yml +18 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +164 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +320 -0
- data/Guardfile +35 -0
- data/LICENSE +22 -0
- data/README.md +510 -0
- data/Rakefile +17 -0
- data/app/controllers/eventsimple/application_controller.rb +15 -0
- data/app/controllers/eventsimple/entities_controller.rb +59 -0
- data/app/controllers/eventsimple/home_controller.rb +5 -0
- data/app/controllers/eventsimple/models_controller.rb +10 -0
- data/app/views/eventsimple/entities/show.html.erb +109 -0
- data/app/views/eventsimple/home/index.html.erb +0 -0
- data/app/views/eventsimple/models/show.html.erb +19 -0
- data/app/views/eventsimple/shared/_header.html.erb +26 -0
- data/app/views/eventsimple/shared/_sidebar.html.erb +19 -0
- data/app/views/eventsimple/shared/_style.html.erb +105 -0
- data/app/views/layouts/eventsimple/application.html.erb +76 -0
- data/catalog-info.yaml +11 -0
- data/config/routes.rb +11 -0
- data/eventsimple.gemspec +42 -0
- data/lib/dry_types.rb +5 -0
- data/lib/eventsimple/configuration.rb +36 -0
- data/lib/eventsimple/data_type.rb +48 -0
- data/lib/eventsimple/dispatcher.rb +17 -0
- data/lib/eventsimple/engine.rb +37 -0
- data/lib/eventsimple/entity.rb +54 -0
- data/lib/eventsimple/event.rb +189 -0
- data/lib/eventsimple/event_dispatcher.rb +93 -0
- data/lib/eventsimple/generators/install_generator.rb +42 -0
- data/lib/eventsimple/generators/outbox/install_generator.rb +31 -0
- data/lib/eventsimple/generators/outbox/templates/create_outbox_cursor.erb +13 -0
- data/lib/eventsimple/generators/templates/create_events.erb +21 -0
- data/lib/eventsimple/generators/templates/event.erb +8 -0
- data/lib/eventsimple/invalid_transition.rb +14 -0
- data/lib/eventsimple/message.rb +23 -0
- data/lib/eventsimple/metadata.rb +11 -0
- data/lib/eventsimple/metadata_type.rb +38 -0
- data/lib/eventsimple/outbox/consumer.rb +52 -0
- data/lib/eventsimple/outbox/models/cursor.rb +25 -0
- data/lib/eventsimple/reactor_worker.rb +18 -0
- data/lib/eventsimple/support/spec_helpers.rb +47 -0
- data/lib/eventsimple/version.rb +5 -0
- data/lib/eventsimple.rb +41 -0
- data/log/development.log +0 -0
- data/sonar-project.properties +4 -0
- metadata +304 -0
@@ -0,0 +1,189 @@
|
|
1
|
+
module Eventsimple
|
2
|
+
module Event
|
3
|
+
require 'globalid'
|
4
|
+
include GlobalID::Identification
|
5
|
+
|
6
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
7
|
+
def drives_events_for(aggregate_klass, aggregate_id:, events_namespace: nil)
|
8
|
+
class_attribute :_events_namespace
|
9
|
+
self._events_namespace = events_namespace
|
10
|
+
|
11
|
+
class_attribute :_aggregate_klass
|
12
|
+
self._aggregate_klass = aggregate_klass
|
13
|
+
|
14
|
+
class_attribute :_aggregate_id
|
15
|
+
self._aggregate_id = aggregate_id
|
16
|
+
|
17
|
+
class_attribute :_outbox_mode
|
18
|
+
class_attribute :_outbox_concurrency
|
19
|
+
|
20
|
+
class_attribute :_on_invalid_transition
|
21
|
+
self._on_invalid_transition = ->(error) { raise error }
|
22
|
+
|
23
|
+
self.inheritance_column = :type
|
24
|
+
self.store_full_sti_class = false
|
25
|
+
|
26
|
+
attribute :metadata, MetadataType.new
|
27
|
+
attr_writer :skip_dispatcher
|
28
|
+
attr_writer :skip_apply_check
|
29
|
+
|
30
|
+
belongs_to _aggregate_klass.model_name.element.to_sym,
|
31
|
+
foreign_key: :aggregate_id,
|
32
|
+
primary_key: _aggregate_id,
|
33
|
+
class_name: _aggregate_klass.name.to_s,
|
34
|
+
inverse_of: :events,
|
35
|
+
autosave: false,
|
36
|
+
validate: false
|
37
|
+
|
38
|
+
default_scope { order('created_at ASC') }
|
39
|
+
|
40
|
+
before_validation :extend_validation
|
41
|
+
after_validation :perform_transition_checks
|
42
|
+
before_create :apply_and_persist
|
43
|
+
after_create :dispatch
|
44
|
+
|
45
|
+
include InstanceMethods
|
46
|
+
extend ClassMethods
|
47
|
+
end
|
48
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
49
|
+
|
50
|
+
module InstanceMethods
|
51
|
+
def skip_dispatcher
|
52
|
+
@skip_dispatcher || false
|
53
|
+
end
|
54
|
+
|
55
|
+
def skip_apply_check
|
56
|
+
@skip_apply_check || false
|
57
|
+
end
|
58
|
+
|
59
|
+
# Apply the event to the aggregate passed in. The default behaviour is a no-op
|
60
|
+
def apply(aggregate); end
|
61
|
+
|
62
|
+
def can_apply?(_aggregate)
|
63
|
+
true
|
64
|
+
end
|
65
|
+
|
66
|
+
def apply_timestamps(aggregate)
|
67
|
+
aggregate.created_at ||= created_at
|
68
|
+
aggregate.updated_at = created_at
|
69
|
+
end
|
70
|
+
|
71
|
+
def perform_transition_checks
|
72
|
+
return if skip_apply_check
|
73
|
+
return if can_apply?(aggregate)
|
74
|
+
|
75
|
+
_on_invalid_transition.call(
|
76
|
+
Eventsimple::InvalidTransition.new(self.class),
|
77
|
+
)
|
78
|
+
|
79
|
+
raise ActiveRecord::Rollback
|
80
|
+
end
|
81
|
+
|
82
|
+
def extend_validation
|
83
|
+
validate_form = self.class.instance_variable_get(:@validate_with)
|
84
|
+
self.aggregate = aggregate.extend(validate_form) if validate_form
|
85
|
+
end
|
86
|
+
|
87
|
+
# Apply the transformation to the aggregate and save it.
|
88
|
+
def apply_and_persist
|
89
|
+
apply(aggregate)
|
90
|
+
apply_timestamps(aggregate)
|
91
|
+
|
92
|
+
# Persist!
|
93
|
+
aggregate.save!
|
94
|
+
|
95
|
+
self.aggregate = aggregate
|
96
|
+
end
|
97
|
+
|
98
|
+
def dispatch
|
99
|
+
EventDispatcher.dispatch(self) unless skip_dispatcher
|
100
|
+
end
|
101
|
+
|
102
|
+
def aggregate
|
103
|
+
public_send(_aggregate_klass.model_name.element)
|
104
|
+
end
|
105
|
+
|
106
|
+
def aggregate=(aggregate)
|
107
|
+
public_send("#{_aggregate_klass.model_name.element}=", aggregate)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
module ClassMethods
|
112
|
+
def validate_with(form_klass)
|
113
|
+
@validate_with = form_klass
|
114
|
+
end
|
115
|
+
|
116
|
+
def rescue_invalid_transition(&block)
|
117
|
+
self._on_invalid_transition = block || ->(error) {}
|
118
|
+
end
|
119
|
+
|
120
|
+
# We don't store the full namespaced class name in the events table.
|
121
|
+
# Events for an entity are expected to be namespaced under _events_namespace.
|
122
|
+
def find_sti_class(type_name)
|
123
|
+
if _events_namespace.blank?
|
124
|
+
super(type_name)
|
125
|
+
else
|
126
|
+
super("#{_events_namespace}::#{type_name}")
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
# Use a no-op deleted class for events that no longer exist in the codebase
|
131
|
+
def sti_class_for(type_name)
|
132
|
+
super
|
133
|
+
rescue ActiveRecord::SubclassNotFound
|
134
|
+
klass_name = "Deleted__#{type_name.demodulize}"
|
135
|
+
return const_get(klass_name) if const_defined?(klass_name)
|
136
|
+
|
137
|
+
# NOTE: this should still update the timestamps for the model to prevent
|
138
|
+
# projection drift (since the original projection will
|
139
|
+
# have the timestamps applied for the deleted event).
|
140
|
+
klass = Class.new(self)
|
141
|
+
|
142
|
+
const_set(klass_name, klass)
|
143
|
+
end
|
144
|
+
|
145
|
+
# We want to automatically retry writes on concurrency failures. However events with sync
|
146
|
+
# reactors may have multiple nested events that are written within the same transaction.
|
147
|
+
# We can only catch and retry writes when they the outermost event encapsulating the whole
|
148
|
+
# transaction.
|
149
|
+
def create(*args, &block)
|
150
|
+
with_locks do
|
151
|
+
with_retries(args) { super }
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
def create!(*args, &block)
|
156
|
+
with_locks do
|
157
|
+
with_retries(args) { super }
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
def with_locks(&block)
|
162
|
+
if _outbox_mode
|
163
|
+
base_class.with_advisory_lock(base_class.name, { transaction: true }, &block)
|
164
|
+
else
|
165
|
+
yield
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
def with_retries(args, &block) # rubocop:disable Metrics/AbcSize
|
170
|
+
entity = args[0][_aggregate_klass.model_name.element.to_sym]
|
171
|
+
|
172
|
+
# Only implement retries when the event is not already inside a transaction.
|
173
|
+
if entity&.persisted? && !existing_transaction_in_progress?
|
174
|
+
Retriable.with_context(:optimistic_locking, on_retry: proc { entity.reload }, &block)
|
175
|
+
else
|
176
|
+
yield
|
177
|
+
end
|
178
|
+
rescue ActiveRecord::StaleObjectError => e
|
179
|
+
raise e unless existing_transaction_in_progress?
|
180
|
+
|
181
|
+
raise e, "#{e.message} No retries are attempted when already inside a transaction."
|
182
|
+
end
|
183
|
+
|
184
|
+
def existing_transaction_in_progress?
|
185
|
+
ActiveRecord::Base.connection.transaction_open?
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Copyright (c) 2017 Kickstarter, PBC
|
4
|
+
|
5
|
+
# Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
# a copy of this software and associated documentation files (the
|
7
|
+
# "Software"), to deal in the Software without restriction, including
|
8
|
+
# without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
# distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
# permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
# the following conditions:
|
12
|
+
|
13
|
+
# The above copyright notice and this permission notice shall be
|
14
|
+
# included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
23
|
+
|
24
|
+
# Dispatcher implementation.
|
25
|
+
module Eventsimple
|
26
|
+
class EventDispatcher
|
27
|
+
# Dispatches events to matching Reactors once.
|
28
|
+
# Called by all events after they are created.
|
29
|
+
def self.dispatch(event)
|
30
|
+
reactors = rules.for(event)
|
31
|
+
reactors.sync.each do |reactor|
|
32
|
+
reactor.new(event).call
|
33
|
+
event.reload
|
34
|
+
end
|
35
|
+
reactors.async.each do |reactor|
|
36
|
+
ReactorWorker.perform_async(event.to_global_id.to_s, reactor.to_s)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.rules
|
41
|
+
@rules ||= RuleSet.new
|
42
|
+
end
|
43
|
+
|
44
|
+
class RuleSet
|
45
|
+
def initialize
|
46
|
+
@rules = Hash.new { |h, k| h[k] = ReactorSet.new }
|
47
|
+
end
|
48
|
+
|
49
|
+
# Register events with their sync and async Reactors
|
50
|
+
def register(events:, sync:, async:)
|
51
|
+
events.each do |event|
|
52
|
+
@rules[event].add_sync sync
|
53
|
+
@rules[event].add_async async
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Return a ReactorSet containing all Reactors matching an Event
|
58
|
+
def for(event)
|
59
|
+
reactors = ReactorSet.new
|
60
|
+
|
61
|
+
@rules.each do |event_class, rule|
|
62
|
+
# Match event by class including ancestors. e.g. All events match a role for BaseEvent.
|
63
|
+
if event.is_a?(event_class)
|
64
|
+
reactors.add_sync rule.sync
|
65
|
+
reactors.add_async rule.async
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
reactors
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
# Contains sync and async reactors. Used to:
|
74
|
+
# * store reactors via Rules#register
|
75
|
+
# * return a set of matching reactors with Rules#for
|
76
|
+
class ReactorSet
|
77
|
+
attr_reader :sync, :async
|
78
|
+
|
79
|
+
def initialize
|
80
|
+
@sync = Set.new
|
81
|
+
@async = Set.new
|
82
|
+
end
|
83
|
+
|
84
|
+
def add_sync(reactors)
|
85
|
+
@sync += reactors
|
86
|
+
end
|
87
|
+
|
88
|
+
def add_async(reactors)
|
89
|
+
@async += reactors
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails/generators'
|
4
|
+
|
5
|
+
module Eventsimple
|
6
|
+
module Generators
|
7
|
+
class EventGenerator < Rails::Generators::Base
|
8
|
+
include Rails::Generators::Migration
|
9
|
+
|
10
|
+
desc "Generate Outbox Table Migration"
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
12
|
+
|
13
|
+
argument :model_name, type: :string
|
14
|
+
|
15
|
+
def self.next_migration_number(dirname)
|
16
|
+
next_migration_number = current_migration_number(dirname) + 1
|
17
|
+
ActiveRecord::Migration.next_migration_number(next_migration_number)
|
18
|
+
end
|
19
|
+
|
20
|
+
def copy_migrations
|
21
|
+
migration_template "create_events.erb",
|
22
|
+
"db/migrate/create_#{model_name.underscore}_events.rb",
|
23
|
+
migration_version: migration_version
|
24
|
+
|
25
|
+
template "event.erb",
|
26
|
+
"app/models/#{model_name.underscore}_event.rb"
|
27
|
+
|
28
|
+
line = "class #{model_name.camelize} < ApplicationRecord"
|
29
|
+
gsub_file "app/models/#{model_name.underscore}.rb", /(#{Regexp.escape(line)})/mi do |match|
|
30
|
+
<<~RUBY
|
31
|
+
#{match}\n extend Eventsimple::Entity
|
32
|
+
event_driven_by #{model_name.camelize}Event, aggregate_id: :id
|
33
|
+
RUBY
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def migration_version
|
38
|
+
"[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails/generators'
|
4
|
+
|
5
|
+
module Eventsimple
|
6
|
+
module Generators
|
7
|
+
module Outbox
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
9
|
+
include Rails::Generators::Migration
|
10
|
+
|
11
|
+
desc "Generate Outbox Table Migration"
|
12
|
+
source_root File.expand_path("templates", __dir__)
|
13
|
+
|
14
|
+
def self.next_migration_number(dirname)
|
15
|
+
next_migration_number = current_migration_number(dirname) + 1
|
16
|
+
ActiveRecord::Migration.next_migration_number(next_migration_number)
|
17
|
+
end
|
18
|
+
|
19
|
+
def copy_migrations
|
20
|
+
migration_template "create_outbox_cursor.erb",
|
21
|
+
"db/migrate/create_eventsimple_outbox_cursor.rb",
|
22
|
+
migration_version: migration_version
|
23
|
+
end
|
24
|
+
|
25
|
+
def migration_version
|
26
|
+
"[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class CreateEventsimpleOutboxCursor < ActiveRecord::Migration<%= migration_version %>
|
4
|
+
def change
|
5
|
+
create_table :eventsimple_outbox_cursors do |t|
|
6
|
+
t.string :event_klass, null: false
|
7
|
+
t.integer :group_number, null: false
|
8
|
+
t.bigint :cursor, null: false
|
9
|
+
|
10
|
+
t.index [:event_klass, :group_number], unique: true
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Create<%= model_name.camelize %>Events < ActiveRecord::Migration[7.0]
|
4
|
+
def change
|
5
|
+
create_table :<%= model_name.underscore %>_events do |t|
|
6
|
+
# Change this to string if your aggregates primary key is a string type
|
7
|
+
t.bigint :aggregate_id, null: false, index: true
|
8
|
+
t.string :idempotency_key, null: true
|
9
|
+
t.string :type, null: false
|
10
|
+
t.json :data, null: false, default: {}
|
11
|
+
t.json :metadata, null: false, default: {}
|
12
|
+
|
13
|
+
t.timestamps
|
14
|
+
|
15
|
+
t.index :idempotency_key, unique: true
|
16
|
+
end
|
17
|
+
|
18
|
+
# Enables optimistic locking on the evented table
|
19
|
+
add_column :<%= model_name.underscore.pluralize %>, :lock_version, :integer
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Eventsimple
|
4
|
+
class Message < Dry::Struct
|
5
|
+
transform_keys(&:to_sym)
|
6
|
+
|
7
|
+
# dry types will apply default values only on missing keys
|
8
|
+
# modify the behaviour so the default is used even when the key is present but nil
|
9
|
+
transform_types do |type|
|
10
|
+
if type.default?
|
11
|
+
type.constructor do |value|
|
12
|
+
value.nil? ? Dry::Types::Undefined : value
|
13
|
+
end
|
14
|
+
else
|
15
|
+
type
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def inspect
|
20
|
+
as_json
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Event metadata store information on the event, for example the user who triggered the event.
|
4
|
+
module Eventsimple
|
5
|
+
class Metadata < Eventsimple::Message
|
6
|
+
schema schema.strict
|
7
|
+
|
8
|
+
attribute? :actor_id, DryTypes::Strict::String
|
9
|
+
attribute? :reason, DryTypes::Strict::String
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Eventsimple
|
4
|
+
class MetadataType < ActiveModel::Type::Value
|
5
|
+
def type
|
6
|
+
:metadata_type
|
7
|
+
end
|
8
|
+
|
9
|
+
def cast_value(value)
|
10
|
+
case value
|
11
|
+
when String
|
12
|
+
decoded = ActiveSupport::JSON.decode(value)
|
13
|
+
return decoded if decoded.empty?
|
14
|
+
|
15
|
+
Eventsimple.configuration.metadata_klass.new(decoded)
|
16
|
+
when Hash
|
17
|
+
Eventsimple.configuration.metadata_klass.new(value)
|
18
|
+
when Eventsimple.configuration.metadata_klass
|
19
|
+
value
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def serialize(value)
|
24
|
+
case value
|
25
|
+
when Hash, Eventsimple.configuration.metadata_klass
|
26
|
+
ActiveSupport::JSON.encode(value)
|
27
|
+
else
|
28
|
+
super
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def deserialize(value)
|
33
|
+
decoded = ActiveSupport::JSON.decode(value)
|
34
|
+
|
35
|
+
Eventsimple.configuration.metadata_klass.new(decoded)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'eventsimple/outbox/models/cursor'
|
4
|
+
|
5
|
+
module Eventsimple
|
6
|
+
module Outbox
|
7
|
+
module Consumer
|
8
|
+
def self.extended(klass)
|
9
|
+
klass.class_exec do
|
10
|
+
class_attribute :_event_klass
|
11
|
+
class_attribute :_processor_klass
|
12
|
+
class_attribute :stop_consumer, default: false
|
13
|
+
|
14
|
+
Signal.trap('SIGINT') do
|
15
|
+
self.stop_consumer = true
|
16
|
+
STDOUT.puts('SIGINT received, stopping consumer')
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def consumes_event(event_klass, concurrency: 1)
|
22
|
+
event_klass._outbox_mode = true
|
23
|
+
event_klass._outbox_concurrency = concurrency
|
24
|
+
|
25
|
+
self._event_klass = event_klass
|
26
|
+
end
|
27
|
+
|
28
|
+
def processor(processor_klass)
|
29
|
+
self._processor_klass = processor_klass
|
30
|
+
end
|
31
|
+
|
32
|
+
def start # rubocop:disable Metrics/AbcSize
|
33
|
+
cursor = Outbox::Cursor.fetch(_event_klass, 0)
|
34
|
+
|
35
|
+
until stop_consumer
|
36
|
+
_event_klass.unscoped.in_batches(start: cursor + 1, load: true).each do |batch|
|
37
|
+
batch.each do |event|
|
38
|
+
_processor_klass.new(event).call
|
39
|
+
|
40
|
+
break if stop_consumer
|
41
|
+
end
|
42
|
+
|
43
|
+
cursor = batch.last.id
|
44
|
+
Outbox::Cursor.set(_event_klass, 0, cursor)
|
45
|
+
end
|
46
|
+
|
47
|
+
sleep(1)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Eventsimple
|
4
|
+
module Outbox
|
5
|
+
class Cursor < ApplicationRecord
|
6
|
+
self.table_name = 'eventsimple_outbox_cursors'
|
7
|
+
|
8
|
+
def self.fetch(event_klass, group_number)
|
9
|
+
existing = find_by(event_klass: event_klass.to_s, group_number: group_number)
|
10
|
+
existing ? existing.cursor : 0
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.set(event_klass, group_number, cursor)
|
14
|
+
upsert(
|
15
|
+
{
|
16
|
+
event_klass: event_klass.to_s,
|
17
|
+
group_number: group_number,
|
18
|
+
cursor: cursor,
|
19
|
+
},
|
20
|
+
unique_by: [:event_klass, :group_number],
|
21
|
+
)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Eventsimple
|
4
|
+
class ReactorWorker
|
5
|
+
include Sidekiq::Worker
|
6
|
+
|
7
|
+
def perform(event_global_id, reactor_class)
|
8
|
+
event = Retriable.with_context(:reactor) do
|
9
|
+
ApplicationRecord.uncached { GlobalID::Locator.locate event_global_id }
|
10
|
+
end
|
11
|
+
rescue ActiveRecord::RecordNotFound
|
12
|
+
Rails.logger.error("Event #{event_global_id} not found for reactor: #{reactor_class}")
|
13
|
+
else
|
14
|
+
reactor = reactor_class.constantize
|
15
|
+
reactor.new(event).call
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
RSpec.shared_examples 'an event which synchronously dispatches' do |dispatcher_klass|
|
4
|
+
specify do
|
5
|
+
reactors = Eventsimple::EventDispatcher.rules.for(described_class.new)
|
6
|
+
|
7
|
+
expect(reactors.sync).to include(dispatcher_klass)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
RSpec.shared_examples 'an event which asynchronously dispatches' do |dispatcher_klass|
|
12
|
+
specify do
|
13
|
+
reactors = Eventsimple::EventDispatcher.rules.for(described_class.new)
|
14
|
+
|
15
|
+
expect(reactors.async).to include(dispatcher_klass)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
RSpec.shared_examples 'an event in invalid state' do
|
20
|
+
it 'raises an InvalidTransition error' do
|
21
|
+
expect { event.save }.to raise_error(Eventsimple::InvalidTransition).and not_change(
|
22
|
+
event.class, :count
|
23
|
+
)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
RSpec.shared_examples 'an event in invalid state that is rescued' do
|
28
|
+
context 'when save' do
|
29
|
+
it 'does not InvalidTransition error on save' do
|
30
|
+
expect { event.save }.not_to raise_error
|
31
|
+
end
|
32
|
+
|
33
|
+
it 'does not write event on save' do
|
34
|
+
expect { event.save }.not_to change(event.class, :count)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
context 'when save!' do
|
39
|
+
it 'does not InvalidTransition error on save!' do
|
40
|
+
expect { event.save! }.not_to raise_error
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'does not write event on save!' do
|
44
|
+
expect { event.save! }.not_to change(event.class, :count)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
data/lib/eventsimple.rb
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "eventsimple/version"
|
4
|
+
require "eventsimple/engine"
|
5
|
+
|
6
|
+
require 'active_model'
|
7
|
+
require 'active_support'
|
8
|
+
require 'dry-types'
|
9
|
+
require 'dry-struct'
|
10
|
+
require 'retriable'
|
11
|
+
require 'sidekiq'
|
12
|
+
|
13
|
+
require 'dry_types'
|
14
|
+
|
15
|
+
require 'eventsimple/configuration'
|
16
|
+
require 'eventsimple/message'
|
17
|
+
require 'eventsimple/data_type'
|
18
|
+
require 'eventsimple/metadata_type'
|
19
|
+
require 'eventsimple/metadata'
|
20
|
+
require 'eventsimple/dispatcher'
|
21
|
+
require 'eventsimple/event_dispatcher'
|
22
|
+
require 'eventsimple/reactor_worker'
|
23
|
+
require 'eventsimple/invalid_transition'
|
24
|
+
|
25
|
+
require 'eventsimple/entity'
|
26
|
+
require 'eventsimple/event'
|
27
|
+
|
28
|
+
require 'eventsimple/generators/install_generator'
|
29
|
+
require 'eventsimple/generators/outbox/install_generator'
|
30
|
+
|
31
|
+
module Eventsimple
|
32
|
+
class << self
|
33
|
+
def configuration
|
34
|
+
@configuration ||= Configuration.new
|
35
|
+
end
|
36
|
+
|
37
|
+
def configure
|
38
|
+
yield(configuration)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
data/log/development.log
ADDED
File without changes
|