├── .rspec ├── Gemfile ├── spec ├── support │ ├── logging.rb │ ├── events.rb │ └── warnings.rb ├── event_sourcery_spec.rb ├── event_sourcery │ ├── event_store │ │ ├── poll_waiter_spec.rb │ │ ├── event_source_spec.rb │ │ ├── signal_handling_subscription_master_spec.rb │ │ ├── event_builder_spec.rb │ │ ├── event_type_serializers │ │ │ ├── class_name_spec.rb │ │ │ └── underscored_spec.rb │ │ └── subscription_spec.rb │ ├── memory │ │ ├── tracker_spec.rb │ │ ├── event_store_spec.rb │ │ ├── config_spec.rb │ │ └── projector_spec.rb │ ├── config_spec.rb │ ├── event_processing │ │ ├── event_stream_processor_registry_spec.rb │ │ ├── error_handlers │ │ │ ├── no_retry_spec.rb │ │ │ ├── constant_retry_spec.rb │ │ │ └── exponential_backoff_retry_spec.rb │ │ ├── esp_process_spec.rb │ │ ├── esp_runner_spec.rb │ │ └── event_stream_processor_spec.rb │ ├── event_body_serializer_spec.rb │ ├── repository_spec.rb │ ├── aggregate_root_spec.rb │ └── event_spec.rb └── spec_helper.rb ├── lib ├── event_sourcery │ ├── version.rb │ ├── event_store │ │ ├── event_sink.rb │ │ ├── event_type_serializers │ │ │ ├── legacy.rb │ │ │ ├── class_name.rb │ │ │ └── underscored.rb │ │ ├── event_source.rb │ │ ├── event_builder.rb │ │ ├── each_by_range.rb │ │ ├── poll_waiter.rb │ │ ├── signal_handling_subscription_master.rb │ │ └── subscription.rb │ ├── event_processing │ │ ├── error_handlers │ │ │ ├── no_retry.rb │ │ │ ├── error_handler.rb │ │ │ ├── constant_retry.rb │ │ │ └── exponential_backoff_retry.rb │ │ ├── event_stream_processor_registry.rb │ │ ├── esp_process.rb │ │ ├── esp_runner.rb │ │ └── event_stream_processor.rb │ ├── memory │ │ ├── projector.rb │ │ ├── config.rb │ │ ├── tracker.rb │ │ └── event_store.rb │ ├── errors.rb │ ├── event_body_serializer.rb │ ├── repository.rb │ ├── config.rb │ ├── aggregate_root.rb │ ├── event.rb │ └── rspec │ │ └── event_store_shared_examples.rb └── event_sourcery.rb ├── bin ├── console └── setup ├── Rakefile ├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE.txt ├── event_sourcery.gemspec ├── CODE_OF_CONDUCT.md ├── CHANGELOG.md └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | ruby '>= 2.2.0' 4 | 5 | gemspec 6 | -------------------------------------------------------------------------------- /spec/support/logging.rb: -------------------------------------------------------------------------------- 1 | # quiet logging 2 | EventSourcery.config.logger.level = Logger::FATAL 3 | -------------------------------------------------------------------------------- /lib/event_sourcery/version.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | # Defines the version 3 | VERSION = '1.0.0'.freeze 4 | end 5 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "event_sourcery" 5 | require "pry" 6 | Pry.start 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /spec/event_sourcery_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe EventSourcery do 2 | it 'has a version number' do 3 | expect(EventSourcery::VERSION).not_to be nil 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/events.rb: -------------------------------------------------------------------------------- 1 | ItemAdded = Class.new(EventSourcery::Event) 2 | ItemRemoved = Class.new(EventSourcery::Event) 3 | TermsAccepted = Class.new(EventSourcery::Event) 4 | -------------------------------------------------------------------------------- /spec/support/warnings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.7.2") 4 | Warning[:deprecated] = true 5 | end 6 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | echo 6 | echo "--- Bundling" 7 | echo 8 | 9 | bundle install 10 | 11 | echo 12 | echo "--- Preparing databases" 13 | echo 14 | 15 | createdb event_sourcery_test 16 | -------------------------------------------------------------------------------- /spec/event_sourcery/event_store/poll_waiter_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe EventSourcery::EventStore::PollWaiter do 2 | subject(:poll_waiter) { described_class.new(interval: 0) } 3 | 4 | it 'calls the block and sleeps' do 5 | count = 0 6 | poll_waiter.poll do 7 | count += 1 8 | throw :stop if count == 3 9 | end 10 | expect(count).to eq 3 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/event_sourcery/event_store/event_sink.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | 3 | module EventSourcery 4 | module EventStore 5 | class EventSink 6 | def initialize(event_store) 7 | @event_store = event_store 8 | end 9 | 10 | extend Forwardable 11 | def_delegators :event_store, :sink 12 | 13 | private 14 | 15 | attr_reader :event_store 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/event_sourcery/memory/tracker_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe EventSourcery::Memory::Tracker do 2 | let(:processor_name) { 'test-processor' } 3 | subject(:adapter) { described_class.new } 4 | 5 | it 'tracks processed events' do 6 | expect(adapter.last_processed_event_id(processor_name)).to eq 0 7 | adapter.processed_event(processor_name, 1) 8 | adapter.processed_event(processor_name, 2) 9 | expect(adapter.last_processed_event_id(processor_name)).to eq 2 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/event_sourcery/event_store/event_type_serializers/legacy.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | module EventStore 3 | module EventTypeSerializers 4 | # To support legacy implementations. Type is provided when initializing 5 | # the event, not derived from the class constant 6 | class Legacy 7 | def serialize(event_class) 8 | nil 9 | end 10 | 11 | def deserialize(event_type) 12 | Event 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: tests 3 | on: [push, pull_request] 4 | jobs: 5 | test: 6 | name: Test (Ruby ${{ matrix.ruby }}) 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | ruby: [ '2.6', '2.7', '3.0', '3.1', '3.2', '3.3', '3.4' ] 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Ruby ${{ matrix.ruby }} 14 | uses: ruby/setup-ruby@v1 15 | with: 16 | ruby-version: ${{ matrix.ruby }} 17 | bundler-cache: true 18 | - name: RSpec 19 | run: bundle exec rspec 20 | -------------------------------------------------------------------------------- /lib/event_sourcery/event_store/event_source.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | module EventStore 3 | class EventSource 4 | def initialize(event_store) 5 | @event_store = event_store 6 | end 7 | 8 | extend Forwardable 9 | def_delegators :event_store, 10 | :get_next_from, 11 | :latest_event_id, 12 | :get_events_for_aggregate_id, 13 | :each_by_range, 14 | :subscribe 15 | 16 | private 17 | 18 | attr_reader :event_store 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/event_sourcery/event_store/event_type_serializers/class_name.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | module EventStore 3 | module EventTypeSerializers 4 | # Stores event types by their class name and falls back to the generic 5 | # Event class if the constant is not found 6 | class ClassName 7 | def serialize(event_class) 8 | event_class.name 9 | end 10 | 11 | def deserialize(event_type) 12 | Object.const_get(event_type) 13 | rescue NameError 14 | Event 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/event_sourcery/event_store/event_builder.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | module EventStore 3 | class EventBuilder 4 | def initialize(event_type_serializer:) 5 | @event_type_serializer = event_type_serializer 6 | end 7 | 8 | def build(**event_data) 9 | type = event_data.fetch(:type) 10 | klass = event_type_serializer.deserialize(type) 11 | upcast(klass.new(**event_data)) 12 | end 13 | 14 | private 15 | 16 | attr_reader :event_type_serializer 17 | 18 | def upcast(event) 19 | event.class.upcast(event) 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/event_sourcery/event_processing/error_handlers/no_retry.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | module EventProcessing 3 | module ErrorHandlers 4 | class NoRetry 5 | include EventSourcery::EventProcessing::ErrorHandlers::ErrorHandler 6 | def initialize(processor_name:) 7 | @processor_name = processor_name 8 | end 9 | 10 | # Will yield the block and exit the process if an error is raised. 11 | def with_error_handling 12 | yield 13 | rescue => error 14 | report_error(error) 15 | Process.exit(false) 16 | end 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/event_sourcery/config_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe EventSourcery::Config do 2 | subject(:config) { described_class.new } 3 | 4 | describe '#on_event_processor_critical_error' do 5 | subject(:on_event_processor_critical_error) { config.on_event_processor_critical_error } 6 | 7 | it 'has a default block set' do 8 | expect(on_event_processor_critical_error).to_not be_nil 9 | expect { on_event_processor_critical_error.call(nil, nil) }.to_not raise_error 10 | end 11 | 12 | it 'can set a block' do 13 | block = Object.new 14 | config.on_event_processor_critical_error = block 15 | expect(on_event_processor_critical_error).to be(block) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/event_sourcery/event_store/event_source_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe EventSourcery::EventStore::EventSource do 2 | let(:event_store) { double(:event_store) } 3 | subject(:event_source) { described_class.new(adapter) } 4 | 5 | describe 'adapter delegations' do 6 | let(:adapter) { double } 7 | 8 | %w[ 9 | get_next_from 10 | latest_event_id 11 | get_events_for_aggregate_id 12 | each_by_range 13 | ].each do |method| 14 | it "delegates ##{method} to the adapter" do 15 | allow(event_store).to receive(method.to_sym).and_return([]) 16 | event_store.send(method.to_sym) 17 | expect(event_store).to have_received(method.to_sym) 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/event_sourcery/memory/projector.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | module Memory 3 | module Projector 4 | 5 | def self.included(base) 6 | base.include(EventSourcery::EventProcessing::EventStreamProcessor) 7 | base.include(InstanceMethods) 8 | base.class_eval do 9 | alias_method :project, :process 10 | class << self 11 | alias_method :project, :process 12 | alias_method :projector_name, :processor_name 13 | end 14 | end 15 | end 16 | 17 | module InstanceMethods 18 | def initialize(tracker: EventSourcery::Memory.config.event_tracker) 19 | @tracker = tracker 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/event_sourcery/memory/config.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | module Memory 3 | class Config 4 | attr_accessor :event_tracker 5 | attr_writer :event_store, :event_source, :event_sink 6 | 7 | def initialize 8 | @event_tracker = Memory::Tracker.new 9 | end 10 | 11 | def event_store 12 | @event_store ||= EventStore.new 13 | end 14 | 15 | def event_source 16 | @event_source ||= ::EventSourcery::EventStore::EventSource.new(event_store) 17 | end 18 | 19 | def event_sink 20 | @event_sink ||= ::EventSourcery::EventStore::EventSink.new(event_store) 21 | end 22 | 23 | end 24 | 25 | def self.configure 26 | yield config 27 | end 28 | 29 | def self.config 30 | @config ||= Config.new 31 | end 32 | 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Ruby template 3 | *.gem 4 | *.rbc 5 | /.config 6 | /coverage/ 7 | /InstalledFiles 8 | /pkg/ 9 | /spec/reports/ 10 | /spec/examples.txt 11 | /test/tmp/ 12 | /test/version_tmp/ 13 | /tmp/ 14 | 15 | # Used by dotenv library to load environment variables. 16 | # .env 17 | 18 | ## Documentation cache and generated files: 19 | /.yardoc/ 20 | /_yardoc/ 21 | /doc/ 22 | /rdoc/ 23 | 24 | ## Environment normalization: 25 | /.bundle/ 26 | /vendor/bundle 27 | /lib/bundler/man/ 28 | 29 | # for a library or gem, you might want to ignore these files since the code is 30 | # intended to run in multiple environments; otherwise, check them in: 31 | Gemfile.lock 32 | .ruby-version 33 | .ruby-gemset 34 | 35 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 36 | .rvmrc 37 | 38 | -------------------------------------------------------------------------------- /lib/event_sourcery/event_store/each_by_range.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | module EventStore 3 | module EachByRange 4 | def each_by_range(from_event_id, to_event_id, event_types: nil) 5 | caught_up = false 6 | no_events_left = false 7 | event_id = from_event_id 8 | begin 9 | events = get_next_from(event_id, event_types: event_types) 10 | no_events_left = true if events.empty? 11 | events.each do |event| 12 | yield event 13 | if event.id == to_event_id 14 | caught_up = true 15 | break 16 | end 17 | end 18 | unless no_events_left 19 | event_id = events.last.id + 1 20 | end 21 | end while !caught_up && !no_events_left 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/event_sourcery/errors.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | Error = Class.new(StandardError) 3 | UnableToLockProcessorError = Class.new(Error) 4 | ConcurrencyError = Class.new(Error) 5 | AtomicWriteToMultipleAggregatesNotSupported = Class.new(Error) 6 | MultipleCatchAllHandlersDefined = Class.new(Error) 7 | 8 | class EventProcessingError < Error 9 | attr_reader :event, :processor 10 | 11 | def initialize(event:, processor:) 12 | @event = event 13 | @processor = processor 14 | end 15 | 16 | def message 17 | parts = [] 18 | parts << "#<#{processor.class} @@processor_name=#{processor.processor_name.inspect}>" 19 | parts << "#<#{event.class} @id=#{event.id.inspect}, @uuid=#{event.uuid.inspect}, @type=#{event.type.inspect}>" 20 | parts << "#<#{cause.class}: #{cause.message}>" 21 | parts.join("\n") + "\n" 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/event_sourcery/event_processing/error_handlers/error_handler.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | module EventProcessing 3 | module ErrorHandlers 4 | module ErrorHandler 5 | # The default with_error_handling method. Will always raise NotImplementedError 6 | # 7 | # @raise [NotImplementedError] 8 | def with_error_handling 9 | raise NotImplementedError, 'Please implement #with_error_handling method' 10 | end 11 | 12 | private 13 | 14 | def report_error(error) 15 | error = error.cause if error.instance_of?(EventSourcery::EventProcessingError) 16 | EventSourcery.logger.error("Processor #{@processor_name} died with #{error}.\n#{error.backtrace.join("\n")}") 17 | 18 | EventSourcery.config.on_event_processor_error.call(error, @processor_name) 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/event_sourcery/memory/event_store_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe EventSourcery::Memory::EventStore do 2 | let(:supports_versions) { false } 3 | let(:event) { EventSourcery::Event.new(type: 'blah', aggregate_id: SecureRandom.uuid, body: {}) } 4 | subject(:event_store) { described_class.new([], event_builder: EventSourcery.config.event_builder) } 5 | 6 | include_examples 'an event store' 7 | 8 | it 'ignores an expected_version param' do 9 | expect { 10 | event_store.sink(event) 11 | }.to_not raise_error 12 | end 13 | 14 | it 'passes events to listeners' do 15 | listener = Class.new do 16 | def process(event) 17 | @processed_event = event 18 | end 19 | attr_reader :processed_event 20 | end.new() 21 | event_store.add_listeners(listener) 22 | event_store.sink(event) 23 | expect(listener.processed_event).to eq(event) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/event_sourcery/event_store/signal_handling_subscription_master_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe EventSourcery::EventStore::SignalHandlingSubscriptionMaster do 2 | subject(:subscription_master) { described_class.new } 3 | 4 | describe 'shutdown_if_requested' do 5 | subject(:shutdown_if_requested) { subscription_master.shutdown_if_requested } 6 | 7 | before do 8 | allow(Signal).to receive(:trap) 9 | end 10 | 11 | %i(TERM INT).each do |signal| 12 | context "after receiving a #{signal} signal" do 13 | before do 14 | allow(Signal).to receive(:trap).with(signal).and_yield 15 | end 16 | 17 | it 'throws :stop' do 18 | expect { shutdown_if_requested }.to throw_symbol(:stop) 19 | end 20 | end 21 | end 22 | 23 | context 'given no signal has been received' do 24 | it 'does nothing' do 25 | shutdown_if_requested 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/event_sourcery/event_store/poll_waiter.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | module EventStore 3 | 4 | # This class provides a basic poll waiter implementation that calls the provided block and sleeps for the specified interval, to be used by a {Subscription}. 5 | class PollWaiter 6 | # 7 | # @param interval [Float] Optional. Will default to `0.5` 8 | def initialize(interval: 0.5) 9 | @interval = interval 10 | end 11 | 12 | # Start polling. Call the provided block and sleep. Repeat until `:stop` is thrown (usually via a subscription master). 13 | # 14 | # @param block [Proc] code block to be called when polling 15 | # 16 | # @see SignalHandlingSubscriptionMaster 17 | # @see Subscription 18 | def poll(&block) 19 | catch(:stop) do 20 | loop do 21 | block.call 22 | sleep @interval 23 | end 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/event_sourcery/event_processing/error_handlers/constant_retry.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | module EventProcessing 3 | module ErrorHandlers 4 | # Strategies for error handling. 5 | class ConstantRetry 6 | include EventSourcery::EventProcessing::ErrorHandlers::ErrorHandler 7 | 8 | # The retry interval used with {with_error_handling}. 9 | # 10 | # @api private 11 | DEFAULT_RETRY_INTERVAL = 1 12 | 13 | def initialize(processor_name:) 14 | @processor_name = processor_name 15 | @retry_interval = DEFAULT_RETRY_INTERVAL 16 | end 17 | 18 | # Will yield the block and attempt to retry after a defined retry interval {DEFAULT_RETRY_INTERVAL}. 19 | def with_error_handling 20 | yield 21 | rescue => error 22 | report_error(error) 23 | sleep(@retry_interval) 24 | 25 | retry 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/event_sourcery/event_store/event_builder_spec.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | module EventStore 3 | RSpec.describe EventBuilder do 4 | describe '#build' do 5 | let(:event_builder) { EventBuilder.new(event_type_serializer: EventSourcery.config.event_type_serializer) } 6 | 7 | describe 'upcasting' do 8 | before do 9 | stub_const('Foo', Class.new(EventSourcery::Event) do 10 | def self.upcast(event) 11 | body = event.body 12 | 13 | body['bar'] ||= 'baz' 14 | 15 | event.with(body: body) 16 | end 17 | end) 18 | end 19 | 20 | it 'upcasts the event' do 21 | event = event_builder.build( 22 | type: 'foo', 23 | body: { 24 | 'foo' => 1, 25 | }, 26 | ) 27 | 28 | expect(event.class).to eq Foo 29 | expect(event.body).to eq( 30 | 'foo' => 1, 31 | 'bar' => 'baz', 32 | ) 33 | end 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Envato 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /spec/event_sourcery/memory/config_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe EventSourcery::Memory::Config do 2 | subject(:config) { described_class.new } 3 | 4 | context 'when reading the event_store' do 5 | it 'returns a EventSourcery::Memory::EventStore' do 6 | expect(config.event_store).to be_instance_of(EventSourcery::Memory::EventStore) 7 | end 8 | 9 | it 'returns a EventSourcery::EventStore::EventSource' do 10 | expect(config.event_source).to be_instance_of(EventSourcery::EventStore::EventSource) 11 | end 12 | 13 | it 'returns a EventSourcery::EventStore::EventSink' do 14 | expect(config.event_sink).to be_instance_of(EventSourcery::EventStore::EventSink) 15 | end 16 | 17 | it 'returns a EventSourcery::Memory::Tracker' do 18 | expect(config.event_tracker).to be_instance_of(EventSourcery::Memory::Tracker) 19 | end 20 | 21 | context 'and an event_store is set' do 22 | let(:event_store) { double(:event_store) } 23 | before do 24 | config.event_store = event_store 25 | end 26 | 27 | it 'returns the event_store' do 28 | expect(config.event_store).to eq(event_store) 29 | end 30 | end 31 | 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/event_sourcery/event_processing/event_stream_processor_registry_spec.rb: -------------------------------------------------------------------------------- 1 | module MyProjector 2 | def self.included(base) 3 | base.send(:include, EventSourcery::EventProcessing::EventStreamProcessor) 4 | end 5 | end 6 | module MyReactor 7 | def self.included(base) 8 | base.send(:include, EventSourcery::EventProcessing::EventStreamProcessor) 9 | end 10 | end 11 | 12 | RSpec.describe EventSourcery::EventProcessing::EventStreamProcessorRegistry do 13 | subject(:registry) { described_class.new } 14 | 15 | let(:projector) { Class.new { include MyProjector; processor_name 'projector' } } 16 | let(:reactor) { Class.new { include MyReactor; processor_name 'reactor' } } 17 | 18 | before do 19 | registry.register(projector) 20 | registry.register(reactor) 21 | end 22 | 23 | it 'registers ESPs by processor_name' do 24 | expect(registry.find('projector')).to eq projector 25 | expect(registry.find('reactor')).to eq reactor 26 | end 27 | 28 | it 'returns all ESPs' do 29 | expect(registry.all).to eq [projector, reactor] 30 | end 31 | 32 | it 'can filter by type' do 33 | expect(registry.by_type(MyProjector)).to eq [projector] 34 | end 35 | 36 | it 'can filter to reactors' do 37 | expect(registry.by_type(MyReactor)).to eq [reactor] 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/event_sourcery/event_store/event_type_serializers/class_name_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe EventSourcery::EventStore::EventTypeSerializers::ClassName do 2 | subject(:serializer) { described_class.new } 3 | 4 | describe '#serialize' do 5 | it "doesn't handle Event in a special way" do 6 | expect(serializer.serialize(EventSourcery::Event)).to eq 'EventSourcery::Event' 7 | end 8 | 9 | it 'returns the serializer class name' do 10 | expect(serializer.serialize(ItemAdded)).to eq 'ItemAdded' 11 | expect(serializer.serialize(ItemRemoved)).to eq 'ItemRemoved' 12 | end 13 | end 14 | 15 | describe '#deserialize' do 16 | it 'looks up the constant' do 17 | expect(serializer.deserialize('ItemAdded')).to eq ItemAdded 18 | expect(serializer.deserialize('ItemRemoved')).to eq ItemRemoved 19 | end 20 | 21 | it 'returns Event when not found' do 22 | expect(serializer.deserialize('ItemStarred')).to eq EventSourcery::Event 23 | end 24 | end 25 | 26 | it 'serializes and deserializes to the same constant' do 27 | stub_const 'FooBar', Class.new(EventSourcery::Event) 28 | 29 | expect( 30 | serializer.deserialize( 31 | serializer.serialize( 32 | FooBar, 33 | ) 34 | ) 35 | ).to eq FooBar 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/event_sourcery/event_store/event_type_serializers/underscored_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe EventSourcery::EventStore::EventTypeSerializers::Underscored do 2 | subject(:underscored) { described_class.new } 3 | 4 | describe '#serialize' do 5 | it "doesn't handle Event in a special way" do 6 | expect(underscored.serialize(EventSourcery::Event)).to eq 'event_sourcery/event' 7 | end 8 | 9 | it 'returns the underscored class name' do 10 | expect(underscored.serialize(ItemAdded)).to eq 'item_added' 11 | expect(underscored.serialize(ItemRemoved)).to eq 'item_removed' 12 | end 13 | end 14 | 15 | describe '#deserialize' do 16 | it 'looks up the constant' do 17 | expect(underscored.deserialize('item_added')).to eq ItemAdded 18 | expect(underscored.deserialize('item_removed')).to eq ItemRemoved 19 | end 20 | 21 | it 'returns Event when not found' do 22 | expect(underscored.deserialize('item_starred')).to eq EventSourcery::Event 23 | end 24 | end 25 | 26 | it 'serializes and deserializes to the same constant' do 27 | stub_const 'FooBar', Class.new(EventSourcery::Event) 28 | 29 | expect( 30 | underscored.deserialize( 31 | underscored.serialize( 32 | FooBar, 33 | ) 34 | ) 35 | ).to eq FooBar 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/event_sourcery/event_processing/event_stream_processor_registry.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | module EventProcessing 3 | class EventStreamProcessorRegistry 4 | def initialize 5 | @processors = [] 6 | end 7 | 8 | # Register the class of the Event Stream Processor. 9 | # 10 | # @param klass [Class] the class to register 11 | def register(klass) 12 | @processors << klass 13 | end 14 | 15 | # Find a registered processor by its name. 16 | # 17 | # @param processor_name [String] name of the processor you're looking for 18 | # 19 | # @return [ESProcess, nil] the found processor object or nil 20 | def find(processor_name) 21 | @processors.find do |processor| 22 | processor.processor_name == processor_name 23 | end 24 | end 25 | 26 | # Find a registered processor by its type. 27 | # 28 | # @param constant [String] name of the constant the processor has included 29 | # 30 | # @return [ESProcess, nil] the found processor object or nil 31 | def by_type(constant) 32 | @processors.select do |processor| 33 | processor.included_modules.include?(constant) 34 | end 35 | end 36 | 37 | # Returns an array of all the registered processors. 38 | # 39 | # @return [Array] of all the processors that are registered 40 | def all 41 | @processors 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /event_sourcery.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'event_sourcery/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'event_sourcery' 8 | spec.version = EventSourcery::VERSION 9 | spec.authors = ['Envato'] 10 | spec.email = ['rubygems@envato.com'] 11 | 12 | spec.summary = 'Event Sourcing Library' 13 | spec.description = '' 14 | spec.homepage = 'https://github.com/envato/event_sourcery' 15 | spec.metadata = { 16 | 'bug_tracker_uri' => 'https://github.com/envato/event_sourcery/issues', 17 | 'changelog_uri' => 'https://github.com/envato/event_sourcery/blob/HEAD/CHANGELOG.md', 18 | 'source_code_uri' => 'https://github.com/envato/event_sourcery', 19 | } 20 | 21 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(\.|Gemfile|Rakefile|bin/|script/|spec/)}) } 22 | spec.bindir = 'exe' 23 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 24 | spec.require_paths = ['lib'] 25 | 26 | spec.required_ruby_version = '>= 2.2.0' 27 | 28 | spec.add_development_dependency 'bundler' 29 | spec.add_development_dependency 'rake', '~> 13' 30 | spec.add_development_dependency 'rspec' 31 | spec.add_development_dependency 'pry' 32 | spec.add_development_dependency 'benchmark-ips' 33 | end 34 | -------------------------------------------------------------------------------- /lib/event_sourcery/memory/tracker.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | module Memory 3 | # Being able to know where you're at when reading an event stream 4 | # is important. In here are mechanisms to do so. 5 | class Tracker 6 | # Tracking where you're in an event stream at via an in memory hash. 7 | # Note: This is not persisted and is generally used for testing. 8 | def initialize 9 | @state = Hash.new(0) 10 | end 11 | 12 | # Register a new processor to track or 13 | # reset an existing tracker's last processed event id. 14 | # Will start from 0. 15 | # 16 | # @param processor_name [String] the name of the processor to track 17 | def setup(processor_name) 18 | @state[processor_name.to_s] = 0 19 | end 20 | 21 | # Update the given processor name to the given event id number. 22 | # 23 | # @param processor_name [String] the name of the processor to update 24 | # @param event_id [Int] the number of the event to update 25 | def processed_event(processor_name, event_id) 26 | @state[processor_name.to_s] = event_id 27 | end 28 | 29 | alias :reset_last_processed_event_id :setup 30 | 31 | # Find the last processed event id for a given processor name. 32 | # 33 | # @return [Int] the last event id that the given processor has processed 34 | def last_processed_event_id(processor_name) 35 | @state[processor_name.to_s] 36 | end 37 | 38 | # Returns an array of all the processors that are being tracked. 39 | # 40 | # @return [Array] an array of names of the tracked processors 41 | def tracked_processors 42 | @state.keys 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/event_sourcery/event_store/signal_handling_subscription_master.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | module EventStore 3 | # Manages shutdown signals and facilitate graceful shutdowns of subscriptions. 4 | # 5 | # @see Subscription 6 | class SignalHandlingSubscriptionMaster 7 | def initialize 8 | @shutdown_requested = false 9 | setup_graceful_shutdown 10 | end 11 | 12 | # If a shutdown has been requested through a `TERM` or `INT` signal, this will throw a `:stop` 13 | # (generally) causing a Subscription to stop listening for events. 14 | # 15 | # @see Subscription#start 16 | def shutdown_if_requested 17 | throw :stop if @shutdown_requested 18 | end 19 | 20 | private 21 | 22 | def setup_graceful_shutdown 23 | %i(TERM INT).each do |signal| 24 | Signal.trap(signal) do 25 | @shutdown_requested = true 26 | wakeup_main_thread 27 | end 28 | end 29 | end 30 | 31 | # If the main thread happens to be sleeping when we receive the 32 | # interrupt, wake it up. 33 | # 34 | # Note: the main thread processes the signal trap, hence calling 35 | # Thread.main.wakeup in the signal trap is a no-op as it's undoubtedly 36 | # awake. Instead, we need to fork a new thread, which waits for the main 37 | # thread to go back to sleep and then wakes it up. 38 | def wakeup_main_thread 39 | Thread.fork do 40 | main_thread = Thread.main 41 | 10.times do 42 | if main_thread.status == 'sleep' 43 | main_thread.wakeup 44 | break 45 | end 46 | sleep 0.01 47 | end 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/event_sourcery/event_processing/error_handlers/exponential_backoff_retry.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | module EventProcessing 3 | module ErrorHandlers 4 | class ExponentialBackoffRetry 5 | include EventSourcery::EventProcessing::ErrorHandlers::ErrorHandler 6 | 7 | # The starting value for the retry interval used with {with_error_handling}. 8 | # 9 | # @api private 10 | DEFAULT_RETRY_INTERVAL = 1 11 | 12 | # The maximum retry interval value to be used with {with_error_handling}. 13 | # 14 | # @api private 15 | MAX_RETRY_INTERVAL = 64 16 | 17 | def initialize(processor_name:) 18 | @processor_name = processor_name 19 | @retry_interval = DEFAULT_RETRY_INTERVAL 20 | @error_event_uuid = nil 21 | end 22 | 23 | # Will yield the block and attempt to retry in an exponential backoff. 24 | def with_error_handling 25 | yield 26 | rescue => error 27 | report_error(error) 28 | update_retry_interval(error) 29 | sleep(@retry_interval) 30 | EventSourcery.logger.info { "Retrying #{@processor_name} with error: #{error.message} at interval=#{@retry_interval}" } 31 | retry 32 | end 33 | 34 | private 35 | 36 | def update_retry_interval(error) 37 | if error.instance_of?(EventSourcery::EventProcessingError) 38 | if @error_event_uuid == error.event.uuid 39 | @retry_interval *= 2 if @retry_interval < MAX_RETRY_INTERVAL 40 | else 41 | @error_event_uuid = error.event.uuid 42 | @retry_interval = DEFAULT_RETRY_INTERVAL 43 | end 44 | else 45 | @retry_interval = DEFAULT_RETRY_INTERVAL 46 | end 47 | end 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/event_sourcery/event_processing/esp_process.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | module EventProcessing 3 | class ESPProcess 4 | DEFAULT_AFTER_FORK = -> (event_processor) { } 5 | 6 | def initialize(event_processor:, 7 | event_source:, 8 | subscription_master: EventStore::SignalHandlingSubscriptionMaster.new, 9 | after_fork: nil) 10 | @event_processor = event_processor 11 | @event_source = event_source 12 | @subscription_master = subscription_master 13 | @after_fork = after_fork || DEFAULT_AFTER_FORK 14 | end 15 | 16 | # This will start the Event Stream Processor which will subscribe 17 | # to the event stream source. 18 | def start 19 | name_process 20 | @after_fork.call(@event_processor) 21 | error_handler.with_error_handling do 22 | EventSourcery.logger.info("Starting #{processor_name}") 23 | subscribe_to_event_stream 24 | EventSourcery.logger.info("Stopping #{processor_name}") 25 | end 26 | rescue Exception => e 27 | EventSourcery.logger.fatal("An unhandled exception occurred in #{processor_name}") 28 | EventSourcery.logger.fatal(e) 29 | EventSourcery.config.on_event_processor_critical_error.call(e, processor_name) 30 | raise e 31 | end 32 | 33 | private 34 | 35 | def processor_name 36 | @event_processor.processor_name.to_s 37 | end 38 | 39 | def error_handler 40 | @error_handler ||= EventSourcery.config.error_handler_class.new(processor_name: processor_name) 41 | end 42 | 43 | def name_process 44 | Process.setproctitle(@event_processor.class.name) 45 | end 46 | 47 | def subscribe_to_event_stream 48 | @event_processor.subscribe_to(@event_source, 49 | subscription_master: @subscription_master) 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/event_sourcery/event_body_serializer.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | # EventBodySerializer is used for serializing event bodies when persisting events. 3 | class EventBodySerializer 4 | # Serialize the given event body, with the default or the provided, serializer 5 | # 6 | # @param event_body event body to be serialized 7 | # @param serializer Optional. Serializer to be used. By default {Config#event_body_serializer EventSourcery.config.event_body_serializer} will be used. 8 | def self.serialize(event_body, 9 | serializer: EventSourcery.config.event_body_serializer) 10 | serializer.serialize(event_body) 11 | end 12 | 13 | def initialize 14 | @serializers = Hash.new(IdentitySerializer) 15 | end 16 | 17 | # Register a serializer (instance or block) for the specified type 18 | # 19 | # @param type The type for which the provided serializer will be used for 20 | # @param serializer Optional. A serializer instance for the given type 21 | # @param block_serializer [Proc] Optional. A block that performs the serialization 22 | def add(type, serializer = nil, &block_serializer) 23 | @serializers[type] = serializer || block_serializer 24 | self 25 | end 26 | 27 | # Serialize the given event body 28 | # 29 | # @param object event body to be serialized 30 | def serialize(object) 31 | serializer = @serializers[object.class] 32 | serializer.call(object, &method(:serialize)) 33 | end 34 | 35 | # Default serializer for Hash objects 36 | module HashSerializer 37 | def self.call(hash, &serialize) 38 | hash.each_with_object({}) do |(key, value), memo| 39 | memo[key.to_s] = serialize.call(value) 40 | end 41 | end 42 | end 43 | 44 | # Default serializer for Array objects 45 | module ArraySerializer 46 | def self.call(array, &serialize) 47 | array.map(&serialize) 48 | end 49 | end 50 | 51 | # Default catch all implementation for serializing any object 52 | module IdentitySerializer 53 | def self.call(object) 54 | object 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/event_sourcery/memory/projector_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe EventSourcery::Memory::Projector do 2 | let(:projector_class) do 3 | Class.new do 4 | include EventSourcery::Memory::Projector 5 | processor_name 'test_processor' 6 | end 7 | end 8 | 9 | let(:tracker) { EventSourcery::Memory::Tracker.new } 10 | 11 | subject(:projector) do 12 | projector_class.new( 13 | tracker: tracker 14 | ) 15 | end 16 | 17 | def new_projector(&block) 18 | Class.new do 19 | include EventSourcery::Memory::Projector 20 | projector_name 'test_processor' 21 | class_eval(&block) if block_given? 22 | attr_reader :processed_event 23 | end.new(tracker: tracker) 24 | end 25 | 26 | describe '.new' do 27 | let(:event_tracker) { double } 28 | 29 | before do 30 | allow(EventSourcery::Memory::Tracker).to receive(:new).and_return(event_tracker) 31 | end 32 | 33 | subject(:projector) { projector_class.new } 34 | 35 | it 'uses the inferred event tracker by default' do 36 | expect(projector.instance_variable_get('@tracker')).to eq event_tracker 37 | end 38 | end 39 | 40 | describe '.projector_name' do 41 | it 'delegates to processor_name' do 42 | expect(projector_class.projector_name).to eq 'test_processor' 43 | end 44 | end 45 | 46 | describe '#project' do 47 | let(:item_added_event) { ItemAdded.new } 48 | let(:terms_accepted_event) { TermsAccepted.new } 49 | 50 | it 'processes all events' do 51 | projector = new_projector do 52 | attr_reader :events 53 | 54 | project do |event| 55 | @events ||= [] 56 | @events << event 57 | end 58 | end 59 | 60 | projector.project(item_added_event) 61 | projector.project(terms_accepted_event) 62 | 63 | expect(projector.events).to eq [item_added_event, terms_accepted_event] 64 | end 65 | 66 | it 'processes specified events' do 67 | projector = new_projector do 68 | attr_reader :events 69 | 70 | project ItemAdded do |event| 71 | @events ||= [] 72 | @events << event 73 | end 74 | end 75 | 76 | projector.project(item_added_event) 77 | projector.project(terms_accepted_event) 78 | 79 | expect(projector.events).to eq [item_added_event] 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/event_sourcery/event_processing/error_handlers/no_retry_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe EventSourcery::EventProcessing::ErrorHandlers::NoRetry do 2 | subject(:error_handler) do 3 | described_class.new( 4 | processor_name: processor_name, 5 | ) 6 | end 7 | let(:processor_name) { 'processor_name' } 8 | let(:on_event_processor_error) { spy } 9 | let(:logger) { spy(Logger) } 10 | 11 | before do 12 | allow(EventSourcery.config).to receive(:on_event_processor_error).and_return(on_event_processor_error) 13 | allow(EventSourcery).to receive(:logger).and_return(logger) 14 | allow(logger).to receive(:error) 15 | allow(Process).to receive(:exit) 16 | end 17 | 18 | describe '#with_error_handling' do 19 | let(:cause) { double(to_s: 'OriginalError', backtrace: ['back', 'trace']) } 20 | let(:event) { double(uuid: SecureRandom.uuid) } 21 | subject(:with_error_handling) do 22 | error_handler.with_error_handling do 23 | raise error 24 | end 25 | end 26 | 27 | before do 28 | allow(error).to receive(:cause).and_return(cause) 29 | with_error_handling 30 | end 31 | 32 | context 'when the raised error is StandardError' do 33 | let(:error) { StandardError.new('Some error') } 34 | it 'logs the errors' do 35 | expect(logger).to have_received(:error).once 36 | end 37 | 38 | it 'calls on_event_processor_error with error and processor name' do 39 | expect(on_event_processor_error).to have_received(:call).once 40 | end 41 | 42 | it 'calls Process.exit(false)' do 43 | expect(Process).to have_received(:exit).with(false) 44 | end 45 | end 46 | 47 | context 'when the raised errors are EventProcessingError' do 48 | let(:event_processor) { double :event_processor } 49 | let(:error) { EventSourcery::EventProcessingError.new(event: event, processor: event_processor) } 50 | 51 | it 'logs the original error' do 52 | expect(logger).to have_received(:error).once.with("Processor #{processor_name} died with OriginalError.\nback\ntrace") 53 | end 54 | 55 | it 'calls on_event_processor_error with error and processor name' do 56 | expect(on_event_processor_error).to have_received(:call).once.with(cause, processor_name) 57 | end 58 | 59 | it 'calls Process.exit(false)' do 60 | expect(Process).to have_received(:exit).with(false) 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/event_sourcery/event_store/event_type_serializers/underscored.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | module EventStore 3 | module EventTypeSerializers 4 | # Stores event types by the underscored version of the class name and 5 | # falls back to the generic Event class if the constant is not found 6 | # 7 | # Replace inflector with ActiveSupport like this: 8 | # EventSourcery::EventStore::EventTypeSerializers::Underscored.inflector = ActiveSupport::Inflector 9 | class Underscored 10 | class Inflector 11 | # Inflection methods are taken from active support 3.2 12 | # https://github.com/rails/rails/blob/3-2-stable/activesupport/lib/active_support/inflector/methods.rb 13 | def underscore(camel_cased_word) 14 | word = camel_cased_word.to_s.dup 15 | word.gsub!(/::/, '/') 16 | word.gsub!(/([A-Z\d]+)([A-Z][a-z])/,'\1_\2') 17 | word.gsub!(/([a-z\d])([A-Z])/,'\1_\2') 18 | word.tr!("-", "_") 19 | word.downcase! 20 | word 21 | end 22 | 23 | def camelize(term, uppercase_first_letter = true) 24 | string = term.to_s 25 | if uppercase_first_letter 26 | string = string.sub(/^[a-z\d]*/) { capitalize($&) } 27 | else 28 | string = string.sub(/^(?:(?=\b|[A-Z_])|\w)/) { $&.downcase } 29 | end 30 | string.gsub(/(?:_|(\/))([a-z\d]*)/i) { "#{$1}#{capitalize($2)}" }.gsub('/', '::') 31 | end 32 | 33 | private 34 | 35 | def capitalize(lower_case_and_underscored_word) 36 | result = lower_case_and_underscored_word.to_s.dup 37 | result.gsub!(/_id$/, "") 38 | result.gsub!(/_/, ' ') 39 | result.gsub(/([a-z\d]*)/i) { |match| 40 | "#{match.downcase}" 41 | }.gsub(/^\w/) { $&.upcase } 42 | end 43 | end 44 | 45 | class << self 46 | attr_accessor :inflector 47 | end 48 | @inflector = Inflector.new 49 | 50 | def serialize(event_class) 51 | underscore_class_name(event_class.name) 52 | end 53 | 54 | def deserialize(event_type) 55 | Object.const_get(self.class.inflector.camelize(event_type)) 56 | rescue NameError 57 | Event 58 | end 59 | 60 | private 61 | 62 | def underscore_class_name(class_name) 63 | self.class.inflector.underscore(class_name) 64 | end 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/event_sourcery/repository.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | # This class provides a set of methods to help load and save aggregate instances. 3 | # 4 | # Refer to {https://github.com/envato/event_sourcery_todo_app/blob/31e200f4a2a65be5d847a66a20e23a334d43086b/app/commands/todo/amend.rb#L26 EventSourceryTodoApp} 5 | # for a more complete example. 6 | # @example 7 | # repository = EventSourcery::Repository.new( 8 | # event_source: EventSourceryTodoApp.event_source, 9 | # event_sink: EventSourceryTodoApp.event_sink, 10 | # ) 11 | # 12 | # aggregate = repository.load(Aggregates::Todo, command.aggregate_id) 13 | # aggregate.amend(command.payload) 14 | # repository.save(aggregate) 15 | class Repository 16 | # Create a new instance of the repository and load an aggregate instance 17 | # 18 | # @param aggregate_class Aggregate type 19 | # @param aggregate_id [Integer] ID of the aggregate instance to be loaded 20 | # @param event_source event source to be used for loading the events for the aggregate 21 | # @param event_sink event sink to be used for saving any new events for the aggregate 22 | def self.load(aggregate_class, aggregate_id, event_source:, event_sink:) 23 | new(event_source: event_source, event_sink: event_sink) 24 | .load(aggregate_class, aggregate_id) 25 | end 26 | 27 | # @param event_source event source to be used for loading the events for the aggregate 28 | # @param event_sink event sink to be used for saving any new events for the aggregate 29 | def initialize(event_source:, event_sink:) 30 | @event_source = event_source 31 | @event_sink = event_sink 32 | end 33 | 34 | # Load an aggregate instance 35 | # 36 | # @param aggregate_class Aggregate type 37 | # @param aggregate_id [Integer] ID of the aggregate instance to be loaded 38 | def load(aggregate_class, aggregate_id) 39 | events = event_source.get_events_for_aggregate_id(aggregate_id) 40 | aggregate_class.new(aggregate_id, events) 41 | end 42 | 43 | # Save any new events/changes in the provided aggregate to the event sink 44 | # 45 | # @param aggregate An aggregate instance to be saved 46 | def save(aggregate) 47 | new_events = aggregate.changes 48 | if new_events.any? 49 | event_sink.sink(new_events, 50 | expected_version: aggregate.version - new_events.count) 51 | end 52 | aggregate.clear_changes 53 | end 54 | 55 | private 56 | 57 | attr_reader :event_source, :event_sink 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/event_sourcery/event_processing/error_handlers/constant_retry_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe EventSourcery::EventProcessing::ErrorHandlers::ConstantRetry do 2 | subject(:error_handler) do 3 | described_class.new( 4 | processor_name: processor_name, 5 | ) 6 | end 7 | let(:processor_name) { 'processor_name' } 8 | let(:on_event_processor_error) { spy } 9 | let(:logger) { spy(Logger) } 10 | 11 | before do 12 | allow(EventSourcery.config).to receive(:on_event_processor_error).and_return(on_event_processor_error) 13 | allow(EventSourcery).to receive(:logger).and_return(logger) 14 | allow(logger).to receive(:error) 15 | allow(error_handler).to receive(:sleep) 16 | end 17 | 18 | describe '#with_error_handling' do 19 | let(:cause) { double(to_s: 'OriginalError', backtrace: ['back', 'trace']) } 20 | let(:event) { double(uuid: SecureRandom.uuid) } 21 | let(:number_of_errors_to_raise) { 3 } 22 | subject(:with_error_handling) do 23 | @count = 0 24 | error_handler.with_error_handling do 25 | @count +=1 26 | raise error if @count <= number_of_errors_to_raise 27 | end 28 | end 29 | 30 | context 'when the raised error is StandardError' do 31 | before { with_error_handling } 32 | let(:error) { StandardError.new('Some error') } 33 | it 'logs the errors' do 34 | expect(logger).to have_received(:error).thrice 35 | end 36 | 37 | it 'calls on_event_processor_error with error and processor name' do 38 | expect(on_event_processor_error).to have_received(:call).thrice.with(error, processor_name) 39 | end 40 | 41 | it 'sleeps the process at default interval' do 42 | expect(error_handler).to have_received(:sleep).with(1).thrice 43 | end 44 | end 45 | 46 | context 'when the raised errors are EventProcessingError' do 47 | let(:event_processor) { double :event_processor } 48 | let(:error) { EventSourcery::EventProcessingError.new(event: event, processor: event_processor) } 49 | before do 50 | allow(error).to receive(:cause).and_return(cause) 51 | with_error_handling 52 | end 53 | 54 | it 'logs the original error' do 55 | expect(logger).to have_received(:error).thrice.with("Processor #{processor_name} died with OriginalError.\nback\ntrace") 56 | end 57 | 58 | it 'calls on_event_processor_error with error and processor name' do 59 | expect(on_event_processor_error).to have_received(:call).thrice.with(cause, processor_name) 60 | end 61 | 62 | it 'sleeps the process at default interval' do 63 | expect(error_handler).to have_received(:sleep).with(1).thrice 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/event_sourcery/event_body_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe EventSourcery::EventBodySerializer do 2 | let(:submitted_by_uuid) { SecureRandom.uuid } 3 | let(:submitted_at) { Time.now.utc } 4 | 5 | describe '.serialize' do 6 | subject(:serialize) { described_class.serialize(event_body) } 7 | 8 | context 'when event body contains a Time object' do 9 | let(:event_body) do 10 | { 11 | "submitted_by_uuid" => submitted_by_uuid, 12 | "submitted_at" => submitted_at 13 | } 14 | end 15 | 16 | it 'converts the Time object as an ISO8601 string' do 17 | expected_result = { 18 | "submitted_by_uuid" => submitted_by_uuid, 19 | "submitted_at" => submitted_at.iso8601 20 | } 21 | 22 | expect(serialize).to eq(expected_result) 23 | end 24 | end 25 | 26 | context 'when event body does not contain a Time object' do 27 | let(:event_body) do 28 | { 29 | "submitted_by_uuid" => submitted_by_uuid 30 | } 31 | end 32 | 33 | it 'does no conversions' do 34 | expect(serialize).to eq(event_body) 35 | end 36 | end 37 | 38 | context 'when event body is has a nested hash' do 39 | let(:event_body) do 40 | { 41 | "submitted_at" => submitted_at, 42 | "nested" => { 43 | "submitted_by_uuid" => submitted_by_uuid, 44 | "submitted_at" => submitted_at 45 | } 46 | } 47 | end 48 | 49 | it 'serializes and keeps the nested structure' do 50 | expected_result = { 51 | "submitted_at" => submitted_at.iso8601, 52 | "nested" => { 53 | "submitted_by_uuid" => submitted_by_uuid, 54 | "submitted_at" => submitted_at.iso8601 55 | } 56 | } 57 | 58 | expect(serialize).to eq(expected_result) 59 | end 60 | end 61 | 62 | context 'when event body is a complex data structure with nested arrays and hashes' do 63 | let(:event_body) do 64 | { 65 | "submitted_at" => submitted_at, 66 | "nested" => { 67 | "submitted_by_uuid" => submitted_by_uuid, 68 | "submissions" => [{submitted_at: submitted_at}, {submitted_at: submitted_at}] 69 | } 70 | } 71 | end 72 | 73 | it 'serializes and keeps the nested structure' do 74 | expected_result = { 75 | "submitted_at" => submitted_at.iso8601, 76 | "nested" => { 77 | "submitted_by_uuid" => submitted_by_uuid, 78 | "submissions" =>[{"submitted_at" => submitted_at.iso8601}, { "submitted_at" => submitted_at.iso8601}] 79 | } 80 | } 81 | 82 | expect(serialize).to eq(expected_result) 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/event_sourcery/event_store/subscription.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | module EventStore 3 | 4 | # This allows Event Stream Processors (ESPs) to subscribe to an event store, and be notified when new events are 5 | # added. 6 | class Subscription 7 | # 8 | # @param event_store Event store to source events from 9 | # @param poll_waiter Poll waiter instance used (such as {EventStore::PollWaiter}) for polling the event store 10 | # @param from_event_id [Integer] Start reading events from this event ID 11 | # @param event_types [Array] Optional. If specified, only subscribe to given event types. 12 | # @param on_new_events [Proc] Code block to be executed when new events are received 13 | # @param subscription_master A subscription master instance (such as {EventStore::SignalHandlingSubscriptionMaster}) which orchestrates a graceful shutdown of the subscription, if one is requested. 14 | # @param events_table_name [Symbol] Optional. Defaults to `:events` 15 | def initialize(event_store:, 16 | poll_waiter:, 17 | from_event_id:, 18 | event_types: nil, 19 | on_new_events:, 20 | subscription_master:, 21 | events_table_name: :events, 22 | batch_size: EventSourcery.config.subscription_batch_size) 23 | @event_store = event_store 24 | @from_event_id = from_event_id 25 | @poll_waiter = poll_waiter 26 | @event_types = event_types 27 | @on_new_events = on_new_events 28 | @subscription_master = subscription_master 29 | @current_event_id = from_event_id - 1 30 | @batch_size = batch_size 31 | end 32 | 33 | # Start listening for new events. This method will continue to listen for new events until a shutdown is requested 34 | # through the subscription_master provided. 35 | # 36 | # @see EventStore::SignalHandlingSubscriptionMaster 37 | def start 38 | catch(:stop) do 39 | @poll_waiter.poll do 40 | read_events 41 | end 42 | end 43 | end 44 | 45 | private 46 | 47 | attr_reader :batch_size 48 | 49 | def read_events 50 | loop do 51 | @subscription_master.shutdown_if_requested 52 | events = @event_store.get_next_from(@current_event_id + 1, event_types: @event_types, limit: batch_size) 53 | break if events.empty? 54 | EventSourcery.logger.debug { "New events in subscription: #{events.inspect}" } 55 | @on_new_events.call(events) 56 | @current_event_id = events.last.id 57 | EventSourcery.logger.debug { "Position in stream: #{@current_event_id}" } 58 | end 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/event_sourcery/repository_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe EventSourcery::Repository do 2 | let(:event_store) { EventSourcery::Memory::EventStore.new } 3 | let(:event_sink) { EventSourcery::EventStore::EventSink.new(event_store) } 4 | let(:aggregate_id) { SecureRandom.uuid } 5 | let(:aggregate_class) { 6 | Class.new do 7 | include EventSourcery::AggregateRoot 8 | 9 | apply ItemAdded do |event| 10 | @item_added_events ||= [] 11 | @item_added_events << event 12 | end 13 | attr_reader :item_added_events 14 | end 15 | } 16 | let(:events) { [ItemAdded.new(aggregate_id: aggregate_id)] } 17 | 18 | describe '.load' do 19 | RSpec.shared_examples 'news up an aggregate and loads history' do 20 | let(:added_events) { event_store.get_events_for_aggregate_id(aggregate_id) } 21 | 22 | subject(:aggregate) do 23 | described_class.load(aggregate_class, uuid, event_source: event_store, event_sink: event_sink) 24 | end 25 | 26 | specify { expect(aggregate.item_added_events).to eq(added_events) } 27 | context 'when aggregate_id is a string' do 28 | include_examples 'news up an aggregate and loads history' do 29 | let(:uuid) { aggregate_id } 30 | end 31 | end 32 | 33 | context 'when aggregate_id is convertible to a string' do 34 | include_examples 'news up an aggregate and loads history' do 35 | let(:uuid) { double(to_str: aggregate_id) } 36 | end 37 | end 38 | end 39 | end 40 | 41 | describe '#save' do 42 | let(:version) { 20 } 43 | let(:aggregate) { double(EventSourcery::AggregateRoot, changes: changes, id: aggregate_id, version: version, clear_changes: nil) } 44 | let(:event_sink) { double(EventSourcery::EventStore::EventSink, sink: nil) } 45 | let(:event_source) { double(EventSourcery::EventStore::EventSink, get_events_for_aggregate_id: nil) } 46 | subject(:repository) { EventSourcery::Repository.new(event_source: event_source, event_sink: event_sink) } 47 | 48 | context 'when there are no changes' do 49 | let(:changes) { [] } 50 | 51 | it 'does nothing' do 52 | repository.save(aggregate) 53 | expect(event_sink).to_not have_received(:sink) 54 | end 55 | end 56 | 57 | context 'with one change' do 58 | let(:changes) { [ItemAdded.new(body: { title: 'Space Jam' })] } 59 | 60 | it 'saves the new events with the expected version set to the aggregate version minus the number of new events' do 61 | repository.save(aggregate) 62 | expect(event_sink).to have_received(:sink).with(changes, expected_version: version - changes.count) 63 | end 64 | end 65 | 66 | context 'with multiple changes' do 67 | let(:changes) { [ItemAdded.new(body: { title: 'Space Jam' }), ItemRemoved.new(body: { title: 'Space Jam' })] } 68 | 69 | it 'saves the new events with the expected version set to the aggregate version' do 70 | repository.save(aggregate) 71 | expect(event_sink).to have_received(:sink).with(changes, expected_version: version - changes.count) 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/event_sourcery.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'securerandom' 3 | require 'time' 4 | 5 | require 'event_sourcery/version' 6 | require 'event_sourcery/event' 7 | require 'event_sourcery/event_store/event_sink' 8 | require 'event_sourcery/event_store/event_source' 9 | require 'event_sourcery/errors' 10 | require 'event_sourcery/event_store/each_by_range' 11 | require 'event_sourcery/event_store/subscription' 12 | require 'event_sourcery/event_store/poll_waiter' 13 | require 'event_sourcery/event_store/event_builder' 14 | require 'event_sourcery/event_store/event_type_serializers/class_name' 15 | require 'event_sourcery/event_store/event_type_serializers/legacy' 16 | require 'event_sourcery/event_store/event_type_serializers/underscored' 17 | require 'event_sourcery/event_store/signal_handling_subscription_master' 18 | require 'event_sourcery/event_processing/error_handlers/error_handler' 19 | require 'event_sourcery/event_processing/error_handlers/no_retry' 20 | require 'event_sourcery/event_processing/error_handlers/constant_retry' 21 | require 'event_sourcery/event_processing/error_handlers/exponential_backoff_retry' 22 | require 'event_sourcery/event_processing/esp_process' 23 | require 'event_sourcery/event_processing/esp_runner' 24 | require 'event_sourcery/event_processing/event_stream_processor' 25 | require 'event_sourcery/event_processing/event_stream_processor_registry' 26 | require 'event_sourcery/config' 27 | require 'event_sourcery/event_body_serializer' 28 | require 'event_sourcery/aggregate_root' 29 | require 'event_sourcery/repository' 30 | require 'event_sourcery/memory/tracker' 31 | require 'event_sourcery/memory/event_store' 32 | require 'event_sourcery/memory/config' 33 | require 'event_sourcery/memory/projector' 34 | 35 | module EventSourcery 36 | # Configure EventSourcery 37 | # 38 | # @example 39 | # EventSourcery.configure do |config| 40 | # # Add custom reporting of errors occurring during event processing. 41 | # # One might set up an error reporting service like Rollbar here. 42 | # config.on_event_processor_error = proc { |exception, processor_name| … } 43 | # 44 | # # Enable Event Sourcery logging. 45 | # config.logger = Logger.new('logs/my_event_sourcery_app.log') 46 | # 47 | # # Customize how event body attributes are serialized 48 | # config.event_body_serializer 49 | # .add(BigDecimal) { |decimal| decimal.to_s('F') } 50 | # 51 | # # Config how you want to handle event processing errors 52 | # config.error_handler_class = EventSourcery::EventProcessing::ErrorHandlers::ExponentialBackoffRetry 53 | # end 54 | # 55 | # @see Config 56 | def self.configure 57 | yield config 58 | end 59 | 60 | def self.config 61 | @config ||= Config.new 62 | end 63 | 64 | # Logger object used by EventSourcery. Set via `configure`. 65 | # 66 | # @see Config.logger 67 | def self.logger 68 | config.logger 69 | end 70 | 71 | # Registry of all Event Stream Processors 72 | # 73 | # @return EventProcessing::EventStreamProcessorRegistry 74 | def self.event_stream_processor_registry 75 | @event_stream_processor_registry ||= EventProcessing::EventStreamProcessorRegistry.new 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at odindutton@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /lib/event_sourcery/config.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | 3 | module EventSourcery 4 | class Config 5 | # The default Proc to be run when an aggregate loads an event type that 6 | # it doesn't know how to handle. 7 | # What's specified here can be overridden when instantiating an aggregate 8 | # instance. {AggregateRoot#initialize} 9 | # 10 | # If no custom Proc is set, by default behaviour is to raise {AggregateRoot::UnknownEventError} 11 | # 12 | # @return Proc 13 | attr_accessor :on_unknown_event 14 | 15 | # A Proc to be executed on an event processor error. 16 | # App specific custom logic can be provided. 17 | # i.e. report to an error reporting service like Rollbar. 18 | # 19 | # @return Proc 20 | attr_accessor :on_event_processor_error 21 | 22 | # A Proc to be executed on an event processor critical error. 23 | # App defined behaviour can be provided. This will be called 24 | # if an exception causes an a event processor to die. 25 | # i.e. report to an error reporting service like Rollbar. 26 | # 27 | # @return Proc 28 | attr_accessor :on_event_processor_critical_error 29 | 30 | # @return EventStore::EventTypeSerializers::Underscored 31 | attr_accessor :event_type_serializer 32 | 33 | # @return EventProcessing::ErrorHandlers::ConstantRetry 34 | attr_accessor :error_handler_class 35 | 36 | attr_writer :logger, 37 | :event_body_serializer, 38 | :event_builder 39 | 40 | # @return Integer 41 | attr_accessor :subscription_batch_size 42 | 43 | # @api private 44 | def initialize 45 | @on_unknown_event = proc { |event, aggregate| 46 | raise AggregateRoot::UnknownEventError, "#{event.type} is unknown to #{aggregate.class.name}" 47 | } 48 | @on_event_processor_error = proc { |exception, processor_name| 49 | # app specific custom logic ie. report to an error reporting service like Rollbar. 50 | } 51 | @on_event_processor_critical_error = proc { |exception, processor_name| 52 | # app specific custom logic ie. report to an error reporting service like Rollbar. 53 | } 54 | @event_builder = nil 55 | @event_type_serializer = EventStore::EventTypeSerializers::Underscored.new 56 | @error_handler_class = EventProcessing::ErrorHandlers::ConstantRetry 57 | @subscription_batch_size = 1000 58 | end 59 | 60 | # Logger instance used by EventSourcery. 61 | # By default EventSourcery will log to STDOUT with a log level of Logger::INFO 62 | def logger 63 | @logger ||= ::Logger.new(STDOUT).tap do |logger| 64 | logger.level = Logger::INFO 65 | end 66 | end 67 | 68 | # The event builder used by an event store to build event instances. 69 | # By default {EventStore::EventBuilder} will be used. 70 | # Provide a custom builder here to change how an event is built. 71 | def event_builder 72 | @event_builder || EventStore::EventBuilder.new(event_type_serializer: @event_type_serializer) 73 | end 74 | 75 | # The event body serializer used by the default event builder 76 | # ({EventStore::EventBuilder}). By default {EventBodySerializer} will be used. 77 | # Provide a custom serializer here to change how the event body is serialized. 78 | def event_body_serializer 79 | @event_body_serializer ||= EventBodySerializer.new 80 | .add(Hash, EventBodySerializer::HashSerializer) 81 | .add(Array, EventBodySerializer::ArraySerializer) 82 | .add(Time, &:iso8601) 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/event_sourcery/event_processing/esp_process_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe EventSourcery::EventProcessing::ESPProcess do 2 | subject(:esp_process) do 3 | described_class.new( 4 | event_processor: esp, 5 | event_source: event_source, 6 | subscription_master: subscription_master, 7 | after_fork: after_fork, 8 | ) 9 | end 10 | let(:esp) { spy(:esp, processor_name: processor_name, class: esp_class) } 11 | let(:esp_class) { double(name: 'SomeApp::Reactors::SomeReactor') } 12 | let(:processor_name) { 'processor_name' } 13 | let(:event_source) { spy(:event_source) } 14 | let(:subscription_master) { spy(EventSourcery::EventStore::SignalHandlingSubscriptionMaster) } 15 | let(:error_handler) { double } 16 | let(:after_fork) { nil } 17 | let(:on_event_processor_critical_error) { spy } 18 | 19 | describe '#start' do 20 | subject(:start) { esp_process.start } 21 | let(:logger) { spy(Logger) } 22 | 23 | before do 24 | allow(EventSourcery.config.error_handler_class).to receive(:new) 25 | .with(processor_name: processor_name).and_return(error_handler) 26 | allow(EventSourcery).to receive(:logger).and_return(logger) 27 | allow(EventSourcery.config).to receive(:on_event_processor_critical_error).and_return(on_event_processor_critical_error) 28 | end 29 | 30 | context 'when no error is raised' do 31 | before do 32 | allow(error_handler).to receive(:with_error_handling).and_yield 33 | allow(Process).to receive(:setproctitle) 34 | 35 | allow(esp).to receive(:subscribe_to) 36 | end 37 | 38 | it 'names process with ESP name' do 39 | start 40 | expect(Process).to have_received(:setproctitle).with('SomeApp::Reactors::SomeReactor') 41 | end 42 | 43 | it 'wraps event processing inside error handler' do 44 | start 45 | expect(error_handler).to have_received(:with_error_handling) 46 | end 47 | 48 | it 'logs info when starting ESP' do 49 | start 50 | expect(logger).to have_received(:info).with("Starting #{processor_name}") 51 | end 52 | 53 | it 'subscribes event processor to event store' do 54 | start 55 | expect(esp).to have_received(:subscribe_to) 56 | end 57 | 58 | it 'logs info when stopping ESP' do 59 | start 60 | expect(logger).to have_received(:info).with("Stopping #{processor_name}") 61 | end 62 | 63 | context 'when after_fork is omitted' do 64 | it 'calls the default after_fork with the processor' do 65 | expect(EventSourcery::EventProcessing::ESPProcess::DEFAULT_AFTER_FORK).to receive(:call).with(esp) 66 | described_class.new( 67 | event_processor: esp, 68 | event_source: event_source, 69 | subscription_master: subscription_master, 70 | ).start 71 | end 72 | end 73 | 74 | context 'with after_fork set' do 75 | let(:after_fork) { spy(:after_fork) } 76 | 77 | it 'calls after_fork with the processor' do 78 | start 79 | expect(after_fork).to have_received(:call).with(esp) 80 | end 81 | end 82 | end 83 | 84 | context 'when the raised error is Exception' do 85 | let(:error) { Exception.new('Non-standard error') } 86 | 87 | before do 88 | allow(error_handler).to receive(:with_error_handling).and_raise(error) 89 | end 90 | 91 | it 'logs and re-raises the error' do 92 | expect { start }.to raise_error(error) 93 | expect(logger).to have_received(:fatal).with(error) 94 | expect(on_event_processor_critical_error).to have_received(:call).with(error, processor_name) 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /spec/event_sourcery/event_store/subscription_spec.rb: -------------------------------------------------------------------------------- 1 | class TestPoller 2 | attr_accessor :times, :after_poll_callback 3 | 4 | def initialize(times: 1, after_poll_callback: proc { }) 5 | @times = times 6 | @after_poll_callback = after_poll_callback 7 | end 8 | 9 | def poll(*args, &block) 10 | Array(1..times).each do 11 | yield 12 | after_poll_callback.call 13 | end 14 | end 15 | end 16 | 17 | RSpec.describe EventSourcery::EventStore::Subscription do 18 | def on_new_events_callback(events) 19 | @event_batches << events 20 | end 21 | 22 | let(:event_types) { nil } 23 | let(:event_store) { EventSourcery::Memory::EventStore.new } 24 | subject(:subscription) { described_class.new(event_store: event_store, 25 | poll_waiter: waiter, 26 | event_types: event_types, 27 | from_event_id: 1, 28 | subscription_master: subscription_master, 29 | on_new_events: method(:on_new_events_callback)) } 30 | 31 | let(:waiter) { TestPoller.new } 32 | let(:subscription_master) { spy(EventSourcery::EventStore::SignalHandlingSubscriptionMaster) } 33 | 34 | before do 35 | @event_batches = [] 36 | end 37 | 38 | it 'yields new events' do 39 | event_store.sink(ItemAdded.new(aggregate_id: SecureRandom.uuid)) 40 | subscription.start 41 | expect(@event_batches.first.map(&:id)).to eq [1] 42 | end 43 | 44 | it 'yields new events in batches' do 45 | waiter.times = 2 46 | waiter.after_poll_callback = proc { event_store.sink(ItemAdded.new(aggregate_id: SecureRandom.uuid)) } 47 | event_store.sink(ItemAdded.new(aggregate_id: SecureRandom.uuid)) 48 | event_store.sink(ItemAdded.new(aggregate_id: SecureRandom.uuid)) 49 | subscription.start 50 | expect(@event_batches.first.map(&:id)).to eq [1, 2] 51 | end 52 | 53 | it 'marks a safe point to shutdown' do 54 | subscription.start 55 | expect(subscription_master).to have_received(:shutdown_if_requested) 56 | end 57 | 58 | context 'with event types' do 59 | let(:event_types) { ['item_added', 'item_removed'] } 60 | 61 | it 'filters by the given event type' do 62 | event_store.sink(ItemAdded.new(aggregate_id: SecureRandom.uuid)) 63 | event_store.sink(ItemRemoved.new(aggregate_id: SecureRandom.uuid)) 64 | event_store.sink(TermsAccepted.new(aggregate_id: SecureRandom.uuid)) 65 | waiter.times = 2 66 | waiter.after_poll_callback = proc { event_store.sink(ItemAdded.new(aggregate_id: SecureRandom.uuid)) } 67 | subscription.start 68 | expect(@event_batches.count).to eq 2 69 | expect(@event_batches.first.map(&:type)).to eq ['item_added', 'item_removed'] 70 | expect(@event_batches.last.map(&:type)).to eq ['item_added'] 71 | end 72 | end 73 | 74 | it 'uses a default batch size' do 75 | expect(event_store).to receive(:get_next_from).with(1, event_types: nil, limit: 1000).and_return [] 76 | 77 | subscription.start 78 | end 79 | 80 | it 'allows specifying a batch size' do 81 | subscription = described_class.new(event_store: event_store, 82 | poll_waiter: waiter, 83 | event_types: event_types, 84 | from_event_id: 1, 85 | subscription_master: subscription_master, 86 | on_new_events: method(:on_new_events_callback), 87 | batch_size: 42) 88 | 89 | expect(event_store).to receive(:get_next_from).with(1, event_types: nil, limit: 42).and_return [] 90 | 91 | subscription.start 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/event_sourcery/event_processing/error_handlers/exponential_backoff_retry_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe EventSourcery::EventProcessing::ErrorHandlers::ExponentialBackoffRetry do 2 | subject(:error_handler) do 3 | described_class.new( 4 | processor_name: processor_name, 5 | ) 6 | end 7 | let(:processor_name) { 'processor_name' } 8 | let(:on_event_processor_error) { spy } 9 | let(:logger) { spy(Logger) } 10 | 11 | before do 12 | @sleep_intervals = [] 13 | allow(EventSourcery.config).to receive(:on_event_processor_error).and_return(on_event_processor_error) 14 | allow(EventSourcery).to receive(:logger).and_return(logger) 15 | allow(logger).to receive(:info) 16 | allow(logger).to receive(:error) 17 | allow(error_handler).to receive(:sleep) { |interval| @sleep_intervals << interval } 18 | end 19 | 20 | describe '#with_error_handling' do 21 | let(:event_processor) { double :event_processor } 22 | let(:cause) { double(to_s: 'OriginalError', backtrace: ['back', 'trace']) } 23 | let(:event) { double(uuid: SecureRandom.uuid) } 24 | let(:number_of_errors_to_raise) { 3 } 25 | subject(:with_error_handling) do 26 | @count = 0 27 | error_handler.with_error_handling do 28 | @count +=1 29 | raise error if @count <= number_of_errors_to_raise 30 | end 31 | end 32 | 33 | context 'when the raised error is StandardError' do 34 | before do 35 | allow(error).to receive(:cause).and_return(cause) 36 | with_error_handling 37 | end 38 | 39 | let(:error) { StandardError.new('Some error') } 40 | it 'logs the errors' do 41 | expect(logger).to have_received(:error).thrice 42 | end 43 | 44 | it 'logs the retry' do 45 | expect(logger).to have_received(:info).thrice 46 | end 47 | 48 | it 'calls on_event_processor_error with error and processor name' do 49 | expect(on_event_processor_error).to have_received(:call).thrice.with(error, processor_name) 50 | end 51 | 52 | it 'sleeps the process at default interval' do 53 | expect(@sleep_intervals).to eq [1, 1, 1] 54 | end 55 | end 56 | 57 | context 'when the raised errors are EventProcessingError for the same event' do 58 | before do 59 | allow(error).to receive(:cause).and_return(cause) 60 | with_error_handling 61 | end 62 | 63 | let(:error) { EventSourcery::EventProcessingError.new(event: event, processor: event_processor) } 64 | 65 | it 'logs the original error' do 66 | expect(logger).to have_received(:error).thrice.with("Processor #{processor_name} died with OriginalError.\nback\ntrace") 67 | end 68 | 69 | it 'logs the retry' do 70 | expect(logger).to have_received(:info).thrice 71 | end 72 | 73 | it 'calls on_event_processor_error with error and processor name' do 74 | expect(on_event_processor_error).to have_received(:call).thrice.with(cause, processor_name) 75 | end 76 | 77 | it 'sleeps the process at exponential increasing intervals' do 78 | expect(@sleep_intervals).to eq [1, 2, 4] 79 | end 80 | 81 | context 'when lots of errors are raised for the same event' do 82 | let(:number_of_errors_to_raise) { 10 } 83 | 84 | it 'sleeps the process at exponential increasing intervals' do 85 | expect(@sleep_intervals).to eq [1, 2, 4, 8, 16, 32, 64, 64, 64, 64] 86 | end 87 | end 88 | end 89 | 90 | context 'when the raised errors are EventProcessingError for the different events' do 91 | before do 92 | allow(error_for_event).to receive(:cause).and_return(cause) 93 | allow(error_for_another_event).to receive(:cause).and_return(cause) 94 | with_error_handling 95 | end 96 | 97 | let(:error_for_event) { EventSourcery::EventProcessingError.new(event: event, processor: event_processor) } 98 | let(:another_event) { double(uuid: SecureRandom.uuid) } 99 | let(:error_for_another_event) { EventSourcery::EventProcessingError.new(event: another_event, processor: event_processor) } 100 | subject(:with_error_handling) do 101 | @count = 0 102 | error_handler.with_error_handling do 103 | @count +=1 104 | raise error_for_event if @count <= 3 105 | raise error_for_another_event if @count <= 5 106 | end 107 | end 108 | 109 | it 'resets retry interval when event uuid changes' do 110 | expect(@sleep_intervals).to eq [1, 2, 4, 1, 2] 111 | end 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/event_sourcery/event_processing/esp_runner.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | module EventProcessing 3 | # NOTE: databases should be disconnected before running this 4 | # EventSourcery.config.postgres.event_store_database.disconnect 5 | class ESPRunner 6 | def initialize(event_processors:, 7 | event_source:, 8 | max_seconds_for_processes_to_terminate: 30, 9 | shutdown_requested: false, 10 | after_fork: nil, 11 | after_subprocess_termination: nil, 12 | logger: EventSourcery.logger) 13 | @event_processors = event_processors 14 | @event_source = event_source 15 | @pids = {} 16 | @max_seconds_for_processes_to_terminate = max_seconds_for_processes_to_terminate 17 | @shutdown_requested = shutdown_requested 18 | @exit_status = true 19 | @after_fork = after_fork 20 | @after_subprocess_termination = after_subprocess_termination 21 | @logger = logger 22 | end 23 | 24 | # Start each Event Stream Processor in a new child process. 25 | def start! 26 | with_logging do 27 | start_processes 28 | listen_for_shutdown_signals 29 | while_waiting_for_shutdown do 30 | record_terminated_processes 31 | end 32 | terminate_remaining_processes 33 | until all_processes_terminated? || waited_long_enough? 34 | record_terminated_processes 35 | end 36 | kill_remaining_processes 37 | record_terminated_processes until all_processes_terminated? 38 | end 39 | exit_indicating_status_of_processes 40 | end 41 | 42 | def start_processor(event_processor) 43 | process = ESPProcess.new( 44 | event_processor: event_processor, 45 | event_source: @event_source, 46 | after_fork: @after_fork, 47 | ) 48 | pid = Process.fork { process.start } 49 | @pids[pid] = event_processor 50 | end 51 | 52 | def shutdown 53 | @shutdown_requested = true 54 | end 55 | 56 | def shutdown_requested? 57 | @shutdown_requested 58 | end 59 | 60 | private 61 | 62 | attr_reader :logger 63 | 64 | def with_logging 65 | logger.info('ESPRunner: Forking processes') 66 | yield 67 | logger.info('ESPRunner: Processes shutdown') 68 | end 69 | 70 | def start_processes 71 | @event_processors.each(&method(:start_processor)) 72 | end 73 | 74 | def listen_for_shutdown_signals 75 | %i(TERM INT).each do |signal| 76 | Signal.trap(signal) { shutdown } 77 | end 78 | end 79 | 80 | def while_waiting_for_shutdown 81 | loop do 82 | yield 83 | break if shutdown_requested? 84 | sleep(1) 85 | end 86 | end 87 | 88 | def terminate_remaining_processes 89 | send_signal_to_remaining_processes(:TERM) 90 | end 91 | 92 | def kill_remaining_processes 93 | send_signal_to_remaining_processes(:KILL) 94 | end 95 | 96 | def send_signal_to_remaining_processes(signal) 97 | return if all_processes_terminated? 98 | 99 | logger.info("ESPRunner: Sending #{signal} to [#{@pids.values.map(&:processor_name).join(', ')}]") 100 | Process.kill(signal, *@pids.keys) 101 | rescue Errno::ESRCH 102 | record_terminated_processes 103 | retry 104 | end 105 | 106 | def record_terminated_processes 107 | until all_processes_terminated? || (pid, status = Process.wait2(-1, Process::WNOHANG)).nil? 108 | event_processor = @pids.delete(pid) 109 | logger.info("ESPRunner: Process #{event_processor&.processor_name || pid} " \ 110 | "terminated with exit status: #{status.exitstatus.inspect}") 111 | next unless event_processor 112 | @exit_status &&= !!status.success? 113 | @after_subprocess_termination&.call(processor: event_processor, runner: self, exit_status: status.exitstatus) 114 | end 115 | end 116 | 117 | def all_processes_terminated? 118 | @pids.empty? 119 | end 120 | 121 | def waited_long_enough? 122 | @timeout ||= Time.now + @max_seconds_for_processes_to_terminate 123 | Time.now >= @timeout 124 | end 125 | 126 | def exit_indicating_status_of_processes 127 | Process.exit(@exit_status) 128 | end 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /spec/event_sourcery/aggregate_root_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe EventSourcery::AggregateRoot do 2 | def new_aggregate(id, 3 | on_unknown_event: EventSourcery.config.on_unknown_event, 4 | &block) 5 | Class.new do 6 | include EventSourcery::AggregateRoot 7 | 8 | def initialize(id, 9 | events, 10 | on_unknown_event: -> {}) 11 | @item_added_events = [] 12 | @item_removed_events = [] 13 | @added_and_removed_events = [] 14 | super 15 | end 16 | 17 | apply ItemAdded do |event| 18 | @item_added_events << event 19 | end 20 | 21 | apply(ItemRemoved) do |event| 22 | @item_removed_events << event 23 | end 24 | 25 | apply ItemAdded, ItemRemoved do |event| 26 | @added_and_removed_events << event 27 | end 28 | 29 | attr_reader :item_added_events, 30 | :item_removed_events, 31 | :added_and_removed_events 32 | 33 | class_eval(&block) if block_given? 34 | end.new(id, 35 | events, 36 | on_unknown_event: on_unknown_event) 37 | end 38 | 39 | let(:item) { Struct.new(:id) } 40 | let(:aggregate_uuid) { SecureRandom.uuid } 41 | subject(:aggregate) { new_aggregate(aggregate_uuid) } 42 | 43 | context 'with no initial events' do 44 | let(:events) { [] } 45 | 46 | it 'initialises at version 0' do 47 | expect(aggregate.version).to eq 0 48 | end 49 | end 50 | 51 | context 'with initial events' do 52 | let(:events) { [ItemAdded.new(id: 1), ItemRemoved.new(id: 2)] } 53 | 54 | it 'calls registered handlers' do 55 | expect(aggregate.item_added_events).to eq [events.first] 56 | expect(aggregate.item_removed_events).to eq [events.last] 57 | expect(aggregate.added_and_removed_events).to eq events 58 | end 59 | 60 | it "updates it's version" do 61 | expect(aggregate.version).to eq events.count 62 | end 63 | end 64 | 65 | context "when the aggregate doesn't have a state change method for an event" do 66 | let(:events) { [TermsAccepted.new(id: 1)] } 67 | 68 | context 'using the default on_unknown_event' do 69 | it 'raises an error' do 70 | expect { aggregate } 71 | .to raise_error(EventSourcery::AggregateRoot::UnknownEventError) 72 | end 73 | end 74 | 75 | context 'using a custom on_unknown_event' do 76 | let(:custom_on_unknown_event) { spy } 77 | let(:aggregate) { new_aggregate(aggregate_uuid, on_unknown_event: custom_on_unknown_event) } 78 | 79 | it 'yields the event and aggregate to the on_unknown_event block' do 80 | aggregate 81 | expect(custom_on_unknown_event) 82 | .to have_received(:call) 83 | .with(events.first, kind_of(EventSourcery::AggregateRoot)) 84 | end 85 | end 86 | end 87 | 88 | context 'when state changes' do 89 | let(:events) { [] } 90 | 91 | subject(:aggregate) { 92 | new_aggregate(aggregate_uuid) do 93 | def add_item(item) 94 | apply_event ItemAdded, body: { id: item.id } 95 | end 96 | end 97 | } 98 | 99 | before do 100 | aggregate.add_item(item.new(1234)) 101 | end 102 | 103 | it 'updates state by calling the handler' do 104 | event = aggregate.item_added_events.first 105 | expect(event.type).to eq 'item_added' 106 | expect(event.body).to eq("id" => 1234) 107 | end 108 | 109 | it "increments it's version" do 110 | expect(aggregate.version).to eq 1 111 | end 112 | 113 | it 'exposes the new event in changes' do 114 | emitted_event = aggregate.changes.first 115 | expect(emitted_event.type).to eq 'item_added' 116 | expect(emitted_event.body).to eq('id' => 1234) 117 | expect(emitted_event.aggregate_id).to eq aggregate_uuid 118 | end 119 | 120 | context 'when changes are cleared' do 121 | it 'has no changes' do 122 | aggregate.clear_changes 123 | expect(aggregate.changes).to eq [] 124 | end 125 | 126 | it "maintains it's version" do 127 | aggregate.clear_changes 128 | expect(aggregate.version).to eq 1 129 | end 130 | end 131 | 132 | context 'multiple state changes' do 133 | before do 134 | aggregate.add_item(item.new(1235)) 135 | aggregate.add_item(item.new(1236)) 136 | end 137 | 138 | it 'exposes the events in order' do 139 | emitted_versions = aggregate.changes.map { |e| e.body['id'] } 140 | expect(emitted_versions).to eq([1234, 1235, 1236]) 141 | end 142 | 143 | it "increments it's version" do 144 | expect(aggregate.version).to eq 3 145 | end 146 | end 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /lib/event_sourcery/memory/event_store.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | module Memory 3 | # In-memory event store. 4 | # 5 | # Note: This is not persisted and is generally used for testing. 6 | class EventStore 7 | include EventSourcery::EventStore::EachByRange 8 | 9 | # 10 | # @param events [Array] Optional. Collection of events 11 | # @param event_builder Optional. Event builder instance. Will default to {Config#event_builder} 12 | def initialize(events = [], event_builder: EventSourcery.config.event_builder) 13 | @events = events 14 | @event_builder = event_builder 15 | @listeners = [] 16 | end 17 | 18 | # Store given events to the in-memory store 19 | # 20 | # @param event_or_events Event(s) to be stored 21 | # @param expected_version [Optional] Expected version for the aggregate. This is the version the caller of this method expect the aggregate to be in. If it's different from the expected version a {EventSourcery::ConcurrencyError} will be raised. Defaults to nil. 22 | # @raise EventSourcery::ConcurrencyError 23 | # @return Boolean 24 | def sink(event_or_events, expected_version: nil) 25 | events = Array(event_or_events) 26 | ensure_one_aggregate(events) 27 | 28 | if expected_version && version_for(events.first.aggregate_id) != expected_version 29 | raise ConcurrencyError 30 | end 31 | 32 | events.each do |event| 33 | @events << @event_builder.build( 34 | id: @events.size + 1, 35 | aggregate_id: event.aggregate_id, 36 | type: event.type, 37 | version: next_version(event.aggregate_id), 38 | body: EventBodySerializer.serialize(event.body), 39 | created_at: event.created_at || Time.now.utc, 40 | uuid: event.uuid, 41 | correlation_id: event.correlation_id, 42 | causation_id: event.causation_id, 43 | ) 44 | end 45 | 46 | project_events(events) 47 | 48 | true 49 | end 50 | 51 | # Retrieve a subset of events 52 | # 53 | # @param id Starting from event ID 54 | # @param event_types [Array] Optional. If supplied, only retrieve events of given type(s). 55 | # @param limit [Integer] Optional. Number of events to retrieve (starting from the given event ID). 56 | # @return Array 57 | def get_next_from(id, event_types: nil, limit: 1000) 58 | events = if event_types.nil? 59 | @events 60 | else 61 | @events.select { |e| event_types.include?(e.type) } 62 | end 63 | 64 | events.select { |event| event.id >= id }.first(limit) 65 | end 66 | 67 | # Retrieve the latest event ID 68 | # 69 | # @param event_types [Array] Optional. If supplied, only retrieve events of given type(s). 70 | # @return Integer 71 | def latest_event_id(event_types: nil) 72 | events = if event_types.nil? 73 | @events 74 | else 75 | @events.select { |e| event_types.include?(e.type) } 76 | end 77 | 78 | events.empty? ? 0 : events.last.id 79 | end 80 | 81 | # Get all events for the given aggregate 82 | # 83 | # @param id [String] Aggregate ID (UUID as String) 84 | # @return Array 85 | def get_events_for_aggregate_id(id) 86 | stringified_id = id.to_str 87 | @events.select { |event| event.aggregate_id == stringified_id } 88 | end 89 | 90 | # Next version for the aggregate 91 | # 92 | # @param aggregate_id [String] Aggregate ID (UUID as String) 93 | # @return Integer 94 | def next_version(aggregate_id) 95 | version_for(aggregate_id) + 1 96 | end 97 | 98 | # Current version for the aggregate 99 | # 100 | # @param aggregate_id [String] Aggregate ID (UUID as String) 101 | # @return Integer 102 | def version_for(aggregate_id) 103 | get_events_for_aggregate_id(aggregate_id).count 104 | end 105 | 106 | # Ensure all events have the same aggregate 107 | # 108 | # @param events [Array] Collection of events 109 | # @raise AtomicWriteToMultipleAggregatesNotSupported 110 | def ensure_one_aggregate(events) 111 | unless events.map(&:aggregate_id).uniq.one? 112 | raise AtomicWriteToMultipleAggregatesNotSupported 113 | end 114 | end 115 | 116 | # Adds a listener or listeners to the memory store. 117 | # the #process(event) method will execute whenever an event is emitted 118 | # 119 | # @param listener A single listener or an array of listeners 120 | def add_listeners(listeners) 121 | @listeners.concat(Array(listeners)) 122 | end 123 | 124 | private 125 | 126 | def project_events(events) 127 | events.each do |event| 128 | @listeners.each do |listener| 129 | listener.process(event) 130 | end 131 | end 132 | end 133 | end 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # The generated `.rspec` file contains `--require spec_helper` which will cause 4 | # this file to always be loaded, without a need to explicitly require it in any 5 | # files. 6 | # 7 | # Given that it is always loaded, you are encouraged to keep this file as 8 | # light-weight as possible. Requiring heavyweight dependencies from this file 9 | # will add to the boot time of your test suite on EVERY test run, even for an 10 | # individual file that may not need all of that loaded. Instead, consider making 11 | # a separate helper file that requires the additional dependencies and performs 12 | # the additional setup, and require it from the spec files that actually need 13 | # it. 14 | # 15 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 16 | RSpec.configure do |config| 17 | # rspec-expectations config goes here. You can use an alternate 18 | # assertion/expectation library such as wrong or the stdlib/minitest 19 | # assertions if you prefer. 20 | config.expect_with :rspec do |expectations| 21 | # This option will default to `true` in RSpec 4. It makes the `description` 22 | # and `failure_message` of custom matchers include text for helper methods 23 | # defined using `chain`, e.g.: 24 | # be_bigger_than(2).and_smaller_than(4).description 25 | # # => "be bigger than 2 and smaller than 4" 26 | # ...rather than: 27 | # # => "be bigger than 2" 28 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 29 | end 30 | 31 | # rspec-mocks config goes here. You can use an alternate test double 32 | # library (such as bogus or mocha) by changing the `mock_with` option here. 33 | config.mock_with :rspec do |mocks| 34 | # Prevents you from mocking or stubbing a method that does not exist on 35 | # a real object. This is generally recommended, and will default to 36 | # `true` in RSpec 4. 37 | mocks.verify_partial_doubles = true 38 | end 39 | 40 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 41 | # have no way to turn it off -- the option exists only for backwards 42 | # compatibility in RSpec 3). It causes shared context metadata to be 43 | # inherited by the metadata hash of host groups and examples, rather than 44 | # triggering implicit auto-inclusion in groups with matching metadata. 45 | config.shared_context_metadata_behavior = :apply_to_host_groups 46 | 47 | # This allows you to limit a spec run to individual examples or groups 48 | # you care about by tagging them with `:focus` metadata. When nothing 49 | # is tagged with `:focus`, all examples get run. RSpec also provides 50 | # aliases for `it`, `describe`, and `context` that include `:focus` 51 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 52 | config.filter_run_when_matching :focus 53 | 54 | # Allows RSpec to persist some state between runs in order to support 55 | # the `--only-failures` and `--next-failure` CLI options. We recommend 56 | # you configure your source control system to ignore this file. 57 | config.example_status_persistence_file_path = "spec/examples.txt" 58 | 59 | # Limits the available syntax to the non-monkey patched syntax that is 60 | # recommended. For more details, see: 61 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 62 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 63 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 64 | config.disable_monkey_patching! 65 | 66 | # This setting enables warnings. It's recommended, but in some cases may 67 | # be too noisy due to issues in dependencies. 68 | config.warnings = true 69 | 70 | # Many RSpec users commonly either run the entire suite or an individual 71 | # file, and it's useful to allow more verbose output when running an 72 | # individual spec file. 73 | # if config.files_to_run.one? 74 | # Use the documentation formatter for detailed output, 75 | # unless a formatter has already been configured 76 | # (e.g. via a command-line flag). 77 | # config.default_formatter = "doc" 78 | # end 79 | 80 | # Print the 10 slowest examples and example groups at the 81 | # end of the spec run, to help surface which specs are running 82 | # particularly slow. 83 | # config.profile_examples = 10 84 | 85 | # Run specs in random order to surface order dependencies. If you find an 86 | # order dependency and want to debug it, you can fix the order by providing 87 | # the seed, which is printed after each run. 88 | # --seed 1234 89 | config.order = :random 90 | 91 | # Seed global randomization in this process using the `--seed` CLI option. 92 | # Setting this allows you to use `--seed` to deterministically reproduce 93 | # test failures related to randomization by passing the same `--seed` value 94 | # as the one that triggered the failure. 95 | Kernel.srand config.seed 96 | end 97 | 98 | require 'event_sourcery' 99 | require 'event_sourcery/rspec/event_store_shared_examples' 100 | 101 | Dir.glob(File.join(__dir__, 'support/**/*.rb')) { |f| require f } 102 | -------------------------------------------------------------------------------- /lib/event_sourcery/aggregate_root.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | # 3 | # EventSourcery::AggregateRoot provides a foundation for writing your own aggregate root classes. 4 | # You can use it by including it in your classes, as show in the example code. 5 | # 6 | # Excerpt from {https://github.com/envato/event_sourcery/blob/HEAD/docs/core-concepts.md EventSourcery Core Concepts} on Aggregates follows: 7 | # === Aggregates and Command Handling 8 | # 9 | # An aggregate is a cluster of domain objects that can be treated as a single unit. 10 | # Every transaction is scoped to a single aggregate. An aggregate will have one of its component objects be 11 | # the aggregate root. Any references from outside the aggregate should only go to the aggregate root. 12 | # The root can thus ensure the integrity of the aggregate as a whole. 13 | # 14 | # — DDD Aggregate 15 | # 16 | # Clients execute domain transactions against the system by issuing commands against aggregate roots. 17 | # The result of these commands is new events being saved to the event store. 18 | # A typical EventSourcery application will have one or more aggregate roots with multiple commands. 19 | # 20 | # The following partial example is taken from the EventSourceryTodoApp. 21 | # Refer a more complete example {https://github.com/envato/event_sourcery_todo_app/blob/HEAD/app/aggregates/todo.rb here}. 22 | # 23 | # @example 24 | # module EventSourceryTodoApp 25 | # module Aggregates 26 | # class Todo 27 | # include EventSourcery::AggregateRoot 28 | # 29 | # # An event handler that updates the aggregate's state from a event 30 | # apply TodoAdded do |event| 31 | # @added = true 32 | # end 33 | # 34 | # # Method on the aggregate that processes a command and emits an event as a result 35 | # def add(payload) 36 | # raise UnprocessableEntity, "Todo #{id.inspect} already exists" if added 37 | # 38 | # apply_event(TodoAdded, 39 | # aggregate_id: id, 40 | # body: payload, 41 | # ) 42 | # end 43 | # 44 | # private 45 | # 46 | # attr_reader :added 47 | # end 48 | # end 49 | # end 50 | module AggregateRoot 51 | # Raised when the aggregate doesn't have a method to handle a given event. 52 | # Consider implementing one if you get this error. 53 | UnknownEventError = Class.new(RuntimeError) 54 | 55 | def self.included(base) 56 | base.extend(ClassMethods) 57 | base.class_eval do 58 | @event_handlers = Hash.new { |hash, key| hash[key] = [] } 59 | end 60 | end 61 | 62 | module ClassMethods 63 | # Collection of event handlers for the events that this aggregate cares about 64 | # 65 | # @return Hash 66 | attr_reader :event_handlers 67 | 68 | # Register an event handler for the specified event(s) 69 | # 70 | # @param event_classes one or more event types for which the handler is for 71 | # @param block the event handler 72 | # 73 | # @example 74 | # apply TodoAdded do |event| 75 | # @added = true 76 | # end 77 | def apply(*event_classes, &block) 78 | event_classes.each do |event_class| 79 | @event_handlers[event_class.type] << block 80 | end 81 | end 82 | end 83 | 84 | # Load an aggregate instance based on the given ID and events 85 | # 86 | # @param id [String] ID (a UUID represented as a string) of the aggregate instance to be loaded 87 | # @param events [Array] Events from which the aggregate's current state will be formed 88 | # @param on_unknown_event [Proc] Optional. The proc to be run if an unknown event type (for which no event handler is registered using {ClassMethods#apply}) is to be loaded. 89 | def initialize(id, events, on_unknown_event: EventSourcery.config.on_unknown_event) 90 | @id = id.to_str 91 | @version = 0 92 | @on_unknown_event = on_unknown_event 93 | @changes = [] 94 | load_history(events) 95 | end 96 | 97 | # Collection of new events that are yet to be persisted 98 | # 99 | # @return Array 100 | attr_reader :changes 101 | 102 | # Current version of the aggregate. This is the same as the number of events 103 | # currently loaded by the aggregate. 104 | # 105 | # @return Integer 106 | attr_reader :version 107 | 108 | # Clears any changes present in {changes} 109 | # 110 | # @api private 111 | def clear_changes 112 | @changes.clear 113 | end 114 | 115 | private 116 | 117 | def load_history(events) 118 | events.each do |event| 119 | mutate_state_from(event) 120 | end 121 | end 122 | 123 | attr_reader :id 124 | 125 | def apply_event(event_class, options = {}) 126 | event = event_class.new(**options.merge(aggregate_id: id)) 127 | mutate_state_from(event) 128 | @changes << event 129 | end 130 | 131 | def mutate_state_from(event) 132 | handlers = self.class.event_handlers[event.type] 133 | if handlers.any? 134 | handlers.each do |handler| 135 | instance_exec(event, &handler) 136 | end 137 | else 138 | @on_unknown_event.call(event, self) 139 | end 140 | increment_version 141 | end 142 | 143 | def increment_version 144 | @version += 1 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /lib/event_sourcery/event_processing/event_stream_processor.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | module EventProcessing 3 | module EventStreamProcessor 4 | def self.included(base) 5 | base.extend(ClassMethods) 6 | base.include(InstanceMethods) 7 | base.prepend(ProcessHandler) 8 | EventSourcery.event_stream_processor_registry.register(base) 9 | base.class_eval do 10 | @event_handlers = Hash.new { |hash, key| hash[key] = [] } 11 | @all_event_handler = nil 12 | end 13 | end 14 | 15 | module InstanceMethods 16 | def initialize(tracker:) 17 | @tracker = tracker 18 | end 19 | end 20 | 21 | module ProcessHandler 22 | # Handler that processes the given event. 23 | # 24 | # @raise [EventProcessingError] error raised due to processing isssues 25 | # 26 | # @param event [Event] the event to process 27 | def process(event) 28 | @_event = event 29 | handlers = (self.class.event_handlers[event.type] + [self.class.all_event_handler]).compact 30 | handlers.each do |handler| 31 | instance_exec(event, &handler) 32 | end 33 | @_event = nil 34 | rescue 35 | raise EventProcessingError.new(event: event, processor: self) 36 | end 37 | end 38 | 39 | module ClassMethods 40 | 41 | # @attr_reader processes_event_types [Array] Process Event Types 42 | # @attr_reader event_handlers [Hash] Hash of handler blocks keyed by event 43 | # @attr_reader all_event_handler [Proc] An event handler 44 | attr_reader :processes_event_types, :event_handlers, :all_event_handler 45 | 46 | # Can this class process this event type. 47 | # 48 | # @param event_type the event type to check 49 | # 50 | # @return [True, False] 51 | def processes?(event_type) 52 | processes_event_types && 53 | processes_event_types.include?(event_type.to_s) 54 | end 55 | 56 | # Set the name of the processor. 57 | # Returns the class name if no name is given. 58 | # 59 | # @param name [String] the name of the processor to set 60 | def processor_name(name = nil) 61 | if name 62 | @processor_name = name 63 | else 64 | (defined?(@processor_name) && @processor_name) || self.name 65 | end 66 | end 67 | 68 | # Process the events for the given event types with the given block. 69 | # 70 | # @raise [MultipleCatchAllHandlersDefined] error raised when attempting to define multiple catch all handlers. 71 | # 72 | # @param event_classes the event type classes to process 73 | # @param block the code block used to process 74 | def process(*event_classes, &block) 75 | if event_classes.empty? 76 | if @all_event_handler 77 | raise MultipleCatchAllHandlersDefined, 'Attemping to define multiple catch all event handlers.' 78 | else 79 | @all_event_handler = block 80 | end 81 | else 82 | @processes_event_types ||= [] 83 | event_classes.each do |event_class| 84 | @processes_event_types << event_class.type 85 | @event_handlers[event_class.type] << block 86 | end 87 | end 88 | end 89 | end 90 | 91 | # Calls processes_event_types method on the instance class 92 | def processes_event_types 93 | self.class.processes_event_types 94 | end 95 | 96 | # Set up the event tracker 97 | def setup 98 | tracker.setup(processor_name) 99 | end 100 | 101 | # Reset the event tracker 102 | def reset 103 | tracker.reset_last_processed_event_id(processor_name) 104 | end 105 | 106 | # Return the last processed event id 107 | # 108 | # @return [Int] the id of the last processed event 109 | def last_processed_event_id 110 | tracker.last_processed_event_id(processor_name) 111 | end 112 | 113 | # Calls processor_name method on the instance class 114 | def processor_name 115 | self.class.processor_name 116 | end 117 | 118 | # Calls processes? method on the instance class 119 | def processes?(event_type) 120 | self.class.processes?(event_type) 121 | end 122 | 123 | # Subscribe to the given event source. 124 | # 125 | # @param event_source the event source to subscribe to 126 | # @param subscription_master [SignalHandlingSubscriptionMaster] 127 | def subscribe_to(event_source, subscription_master: EventStore::SignalHandlingSubscriptionMaster.new) 128 | setup 129 | event_source.subscribe(from_id: last_processed_event_id + 1, 130 | event_types: processes_event_types, 131 | subscription_master: subscription_master) do |events| 132 | process_events(events, subscription_master) 133 | end 134 | end 135 | 136 | # @attr_writer tracker the tracker for the class 137 | attr_accessor :tracker 138 | 139 | private 140 | 141 | attr_reader :_event 142 | 143 | def process_events(events, subscription_master) 144 | events.each do |event| 145 | subscription_master.shutdown_if_requested 146 | process(event) 147 | tracker.processed_event(processor_name, event.id) 148 | EventSourcery.logger.debug { "[#{processor_name}] Processed event: #{event.inspect}" } 149 | end 150 | EventSourcery.logger.info { "[#{processor_name}] Processed up to event id: #{events.last.id}" } 151 | end 152 | end 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /lib/event_sourcery/event.rb: -------------------------------------------------------------------------------- 1 | module EventSourcery 2 | # Represents an Event 3 | class Event 4 | include Comparable 5 | 6 | # Event type 7 | # 8 | # Will return `nil` if called on an instance of {EventSourcery::Event}. 9 | def self.type 10 | unless self == Event 11 | EventSourcery.config.event_type_serializer.serialize(self) 12 | end 13 | end 14 | 15 | # Use this method to add "upcasting" to your events. 16 | # 17 | # To upcast your events override the `upcast` class method on your event. 18 | # The `upcast` method will be passed the event allowing you to modify it 19 | # before it is passed to your event processors. 20 | # 21 | # A good place to start is using the `Event#with` method that allows you to 22 | # easily change attributes. 23 | # 24 | # @param Event 25 | # @return Event 26 | # @example 27 | # Foo = Class.new(EventSourcery::Event) do 28 | # def self.upcast(event) 29 | # body = event.body 30 | # 31 | # body['bar'] ||= 'baz' 32 | # 33 | # event.with(body: body) 34 | # end 35 | # end 36 | def self.upcast(event) 37 | event 38 | end 39 | 40 | attr_reader :id, :uuid, :aggregate_id, :type, :body, :version, :created_at, :correlation_id, :causation_id 41 | 42 | # @!attribute [r] id 43 | # @return [Integer] unique identifier at the persistent layer 44 | 45 | # @!attribute [r] uuid 46 | # @return [String] unique identifier (UUID) for this event. 47 | 48 | # @!attribute [r] aggregate_id 49 | # @return [String] aggregate instance UUID to which this event belongs to. 50 | 51 | # @!attribute [r] type 52 | # @return event type 53 | 54 | # @!attribute [r] body 55 | # @return [Hash] Content of the event body. 56 | 57 | # @!attribute [r] version 58 | # @return [String] event version. Used by some event stores to guard against concurrency errors. 59 | 60 | # @!attribute [r] created_at 61 | # @return [Time] Created at timestamp (in UTC) for the event. 62 | 63 | # @!attribute [r] correlation_id 64 | # @return [String] UUID attached to the event that allows reference to a particular transaction or event chain. This value is often supplied as part of a command issued by clients. 65 | 66 | # @!attribute [r] causation_id 67 | # @return [String] UUID of the event that caused this event. 68 | 69 | # 70 | # @param id [Integer] Optional. Unique identifier at the persistent layer. By default this will be set by the underlying persistence layer when persisting the event. 71 | # @param uuid [String] UUID as a string. Optional. Unique identifier for this event. A random UUID will be generated by default. 72 | # @param aggregate_id [String] UUID as a string. Aggregate instance UUID to which this event belongs to. 73 | # @param type [Class] Optional. Event type. {Event.type} will be used by default. 74 | # @param version [String] Optional. Event's aggregate version. Used by some event stores to guard against concurrency errors. 75 | # @param created_at [Time] Optional. Created at timestamp (in UTC) for the event. 76 | # @param correlation_id [String] Optional. UUID attached to the event that allows reference to a particular transaction or event chain. This value is often supplied as part of a command issued by clients. 77 | # @param causation_id [String] Optional. UUID of the event that caused this event. 78 | def initialize(id: nil, 79 | uuid: SecureRandom.uuid, 80 | aggregate_id: nil, 81 | type: nil, 82 | body: nil, 83 | version: nil, 84 | created_at: nil, 85 | correlation_id: nil, 86 | causation_id: nil) 87 | @id = id 88 | @uuid = uuid && uuid.downcase 89 | @aggregate_id = aggregate_id && aggregate_id.to_str 90 | @type = self.class.type || type.to_s 91 | @body = body ? EventSourcery::EventBodySerializer.serialize(body) : {} 92 | @version = version ? Integer(version) : nil 93 | @created_at = created_at 94 | @correlation_id = correlation_id 95 | @causation_id = causation_id 96 | end 97 | 98 | def hash 99 | [self.class, uuid].hash 100 | end 101 | 102 | def eql?(other) 103 | instance_of?(other.class) && uuid.eql?(other.uuid) 104 | end 105 | 106 | def <=>(other) 107 | id <=> other.id if other.is_a? Event 108 | end 109 | 110 | # create a new event identical to the old event except for the provided changes 111 | # 112 | # @param attributes [Hash] 113 | # @return Event 114 | # @example 115 | # old_event = EventSourcery::Event.new(type: "item_added", causation_id: nil) 116 | # new_event = old_event.with(causation_id: "05781bd6-796a-4a58-8573-b109f683fd99") 117 | # 118 | # new_event.type # => "item_added" 119 | # new_event.causation_id # => "05781bd6-796a-4a58-8573-b109f683fd99" 120 | # 121 | # old_event.type # => "item_added" 122 | # old_event.causation_id # => nil 123 | # 124 | # # Of course, with can accept any number of event attributes: 125 | # 126 | # new_event = old_event.with(id: 42, version: 77, body: { 'attr' => 'value' }) 127 | # 128 | # # When using typed events you can also override the event class: 129 | # 130 | # new_event = old_event.with(event_class: ItemRemoved) 131 | # new_event.type # => "item_removed" 132 | # new_event.class # => ItemRemoved 133 | def with(event_class: self.class, **attributes) 134 | if self.class != Event && !attributes[:type].nil? && attributes[:type] != type 135 | raise Error, 'When using typed events change the type by changing the event class.' 136 | end 137 | 138 | event_class.new(**to_h.merge!(attributes)) 139 | end 140 | 141 | # returns a hash of the event attributes 142 | # 143 | # @return Hash 144 | def to_h 145 | { 146 | id: id, 147 | uuid: uuid, 148 | aggregate_id: aggregate_id, 149 | type: type, 150 | body: body, 151 | version: version, 152 | created_at: created_at, 153 | correlation_id: correlation_id, 154 | causation_id: causation_id, 155 | } 156 | end 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /spec/event_sourcery/event_processing/esp_runner_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe EventSourcery::EventProcessing::ESPRunner do 2 | subject(:esp_runner) do 3 | described_class.new( 4 | event_processors: event_processors, 5 | event_source: event_source, 6 | max_seconds_for_processes_to_terminate: 0.01, 7 | shutdown_requested: shutdown_requested, 8 | after_subprocess_termination: after_subprocess_termination, 9 | logger: logger, 10 | ) 11 | end 12 | let(:event_source) { spy(:event_source) } 13 | let(:event_processors) { [esp] } 14 | let(:esp) { spy(:esp, processor_name: processor_name) } 15 | let(:processor_name) { 'processor_name' } 16 | let(:esp_process) { spy } 17 | let(:pid) { 363_298 } 18 | let(:success_status) { instance_double(Process::Status, success?: true, exitstatus: 0) } 19 | let(:failure_status) { instance_double(Process::Status, success?: false, exitstatus: 1) } 20 | let(:shutdown_requested) { true } 21 | let(:after_subprocess_termination) { nil } 22 | let(:logger) { instance_spy(Logger) } 23 | 24 | before do 25 | allow(EventSourcery::EventProcessing::ESPProcess) 26 | .to receive(:new) 27 | .and_return(esp_process) 28 | allow(Process).to receive(:fork).and_yield.and_return(pid) 29 | allow(Process).to receive(:kill) 30 | allow(Process).to receive(:wait2).and_return(nil, [pid, success_status]) 31 | allow(Process).to receive(:exit) 32 | allow(Signal).to receive(:trap) 33 | allow(esp_runner).to receive(:sleep) 34 | end 35 | 36 | describe 'start!' do 37 | subject(:start!) { esp_runner.start! } 38 | 39 | it 'starts ESP processes' do 40 | start! 41 | expect(EventSourcery::EventProcessing::ESPProcess) 42 | .to have_received(:new) 43 | .with( 44 | event_processor: esp, 45 | event_source: event_source, 46 | after_fork: nil, 47 | ) 48 | expect(esp_process).to have_received(:start) 49 | end 50 | 51 | describe 'graceful shutdown' do 52 | %i(TERM INT).each do |signal| 53 | context "upon receiving a #{signal} signal" do 54 | before do 55 | allow(Signal).to receive(:trap).with(signal).and_yield 56 | allow(esp_runner).to receive(:shutdown) 57 | end 58 | 59 | it 'it starts to shutdown' do 60 | start! 61 | expect(esp_runner).to have_received(:shutdown) 62 | end 63 | end 64 | end 65 | 66 | it 'sends processes the TERM signal' do 67 | start! 68 | expect(Process).to have_received(:kill).with(:TERM, pid) 69 | end 70 | 71 | it "logs the TERM signal" do 72 | start! 73 | expect(logger).to have_received(:info).with("ESPRunner: Sending TERM to [#{processor_name}]") 74 | end 75 | 76 | it "logs the process exit status" do 77 | start! 78 | expect(logger).to have_received(:info).with("ESPRunner: Process #{processor_name} terminated with exit status: 0") 79 | end 80 | 81 | context 'given an after subprocess termination hook' do 82 | let(:after_subprocess_termination) { spy } 83 | 84 | it 'calls the after subprocess termination' do 85 | start! 86 | expect(after_subprocess_termination).to have_received(:call) 87 | .with(processor: esp, runner: esp_runner, exit_status: 0) 88 | end 89 | end 90 | 91 | it "exits indicating success" do 92 | start! 93 | expect(Process).to have_received(:exit).with(true) 94 | end 95 | 96 | context 'given shutdown has been requested' do 97 | let(:shutdown_requested) { true } 98 | 99 | context 'but the processes failed before shutdown' do 100 | before do 101 | allow(Process).to receive(:wait2).and_return([pid, failure_status]) 102 | end 103 | 104 | it "logs the process exit status" do 105 | start! 106 | expect(logger).to have_received(:info).with("ESPRunner: Process #{processor_name} terminated with exit status: 1") 107 | end 108 | 109 | it "doesn't send processes the TERM, or KILL signal to the failed process" do 110 | start! 111 | expect(Process).to_not have_received(:kill) 112 | end 113 | 114 | context 'given an after subprocess termination hook' do 115 | let(:after_subprocess_termination) { spy } 116 | 117 | it 'calls the after subprocess termination' do 118 | start! 119 | expect(after_subprocess_termination).to have_received(:call) 120 | .with(processor: esp, runner: esp_runner, exit_status: 1) 121 | end 122 | end 123 | 124 | it 'exits indicating failure' do 125 | start! 126 | expect(Process).to have_received(:exit).with(false) 127 | end 128 | end 129 | end 130 | 131 | context 'given the process exits just before sending signal' do 132 | before do 133 | allow(Process).to receive(:kill).and_raise(Errno::ESRCH) 134 | allow(Process).to receive(:wait2).and_return(nil, [pid, failure_status]) 135 | end 136 | 137 | it "doesn't send the signal more than once" do 138 | start! 139 | expect(Process).to have_received(:kill).with(:TERM, pid).once 140 | end 141 | 142 | it "logs the process exit status" do 143 | start! 144 | expect(logger).to have_received(:info).with("ESPRunner: Process #{processor_name} terminated with exit status: 1") 145 | end 146 | 147 | context 'given an after subprocess termination hook' do 148 | let(:after_subprocess_termination) { spy } 149 | 150 | it 'calls the after subprocess termination' do 151 | start! 152 | expect(after_subprocess_termination).to have_received(:call) 153 | .with(processor: esp, runner: esp_runner, exit_status: 1) 154 | end 155 | end 156 | 157 | it "exits indicating failure" do 158 | start! 159 | expect(Process).to have_received(:exit).with(false) 160 | end 161 | end 162 | 163 | context 'given the process does not terminate until killed' do 164 | before do 165 | @stop_process = false 166 | allow(Process).to receive(:wait2) { [pid, failure_status] if @stop_process } 167 | allow(Process).to receive(:kill).with(:KILL, pid) { @stop_process = true} 168 | end 169 | 170 | it 'sends processes the KILL signal' do 171 | start! 172 | expect(Process).to have_received(:kill).with(:KILL, pid) 173 | end 174 | 175 | it "logs the KILL signal" do 176 | start! 177 | expect(logger).to have_received(:info).with("ESPRunner: Sending KILL to [#{processor_name}]") 178 | end 179 | 180 | it "logs the process exit status" do 181 | start! 182 | expect(logger).to have_received(:info).with("ESPRunner: Process #{processor_name} terminated with exit status: 1") 183 | end 184 | 185 | context 'given an after subprocess termination hook' do 186 | let(:after_subprocess_termination) { spy } 187 | 188 | it 'calls the after subprocess termination' do 189 | start! 190 | expect(after_subprocess_termination).to have_received(:call) 191 | .with(processor: esp, runner: esp_runner, exit_status: 1) 192 | end 193 | end 194 | 195 | it "exits indicating failure" do 196 | start! 197 | expect(Process).to have_received(:exit).with(false) 198 | end 199 | end 200 | 201 | context 'given an unknown subprocess terminates' do 202 | before do 203 | allow(Process).to receive(:wait2).and_return(nil, [pid + 1, success_status], [pid, success_status]) 204 | end 205 | 206 | it 'only logs the exit status for both the known and unknown process' do 207 | start! 208 | expect(logger) 209 | .to have_received(:info) 210 | .with("ESPRunner: Process #{pid + 1} terminated with exit status: 0") 211 | .once 212 | expect(logger) 213 | .to have_received(:info) 214 | .with("ESPRunner: Process #{processor_name} terminated with exit status: 0") 215 | .once 216 | end 217 | 218 | context 'given an after subprocess termination hook' do 219 | let(:after_subprocess_termination) { spy } 220 | 221 | it 'calls the after subprocess termination only once (for the known process)' do 222 | start! 223 | expect(after_subprocess_termination).to have_received(:call).once 224 | end 225 | end 226 | end 227 | end 228 | end 229 | 230 | describe 'start_processor' do 231 | subject(:start_processor) { esp_runner.start_processor(esp) } 232 | 233 | it 'starts an ESP process' do 234 | start_processor 235 | expect(EventSourcery::EventProcessing::ESPProcess) 236 | .to have_received(:new) 237 | .with( 238 | event_processor: esp, 239 | event_source: event_source, 240 | after_fork: nil, 241 | ) 242 | expect(esp_process).to have_received(:start) 243 | end 244 | end 245 | 246 | describe 'shutdown' do 247 | subject(:shutdown) { esp_runner.shutdown } 248 | 249 | let(:shutdown_requested) { false } 250 | 251 | it 'requests the runner to shutdown' do 252 | expect(esp_runner.shutdown_requested?).to be false 253 | shutdown 254 | expect(esp_runner.shutdown_requested?).to be true 255 | end 256 | end 257 | end 258 | -------------------------------------------------------------------------------- /spec/event_sourcery/event_processing/event_stream_processor_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe EventSourcery::EventProcessing::EventStreamProcessor do 2 | let(:tracker) { EventSourcery::Memory::Tracker.new } 3 | 4 | def new_event_processor(&block) 5 | Class.new do 6 | include EventSourcery::EventProcessing::EventStreamProcessor 7 | attr_reader :events 8 | 9 | def initialize(tracker:) 10 | super 11 | @events = [] 12 | end 13 | 14 | class_eval(&block) if block_given? 15 | end.new(tracker: tracker) 16 | end 17 | 18 | it 'registers with the ESP registry' do 19 | registry = EventSourcery::EventProcessing::EventStreamProcessorRegistry.new 20 | allow(EventSourcery).to receive(:event_stream_processor_registry).and_return(registry) 21 | esp = Class.new do 22 | include EventSourcery::EventProcessing::EventStreamProcessor 23 | processor_name 'test' 24 | end 25 | expect(registry.find('test')).to eq esp 26 | end 27 | 28 | describe '#processor_name' do 29 | it 'sets processor name' do 30 | processor = new_event_processor do 31 | processor_name 'my_processor' 32 | end 33 | expect(processor.class.processor_name).to eq 'my_processor' 34 | expect(processor.processor_name).to eq 'my_processor' 35 | end 36 | 37 | it 'defaults to class name' do 38 | processor = new_event_processor 39 | expect(processor.class.processor_name).to eq processor.class.name 40 | expect(processor.processor_name).to eq processor.class.name 41 | end 42 | end 43 | 44 | describe '#processes?' do 45 | it 'returns true for events the processor is interested in' do 46 | event_processor = new_event_processor do 47 | process ItemAdded, ItemRemoved do 48 | # Noop 49 | end 50 | end 51 | expect(event_processor.processes?(:item_added)).to eq true 52 | expect(event_processor.processes?('item_added')).to eq true 53 | expect(event_processor.processes?(:item_removed)).to eq true 54 | expect(event_processor.processes?('item_removed')).to eq true 55 | expect(event_processor.processes?(:blah)).to eq false 56 | expect(event_processor.processes?('blah')).to eq false 57 | end 58 | end 59 | 60 | describe '#reset' do 61 | subject(:event_processor) { 62 | new_event_processor do 63 | processor_name 'my_processor' 64 | process do 65 | # Noop 66 | end 67 | end 68 | } 69 | 70 | before do 71 | event_processor.setup 72 | event_processor.process(ItemAdded.new(id: 1)) 73 | end 74 | 75 | it 'resets last processed event ID' do 76 | event_processor.reset 77 | expect(tracker.last_processed_event_id(:test_processor)).to eq 0 78 | end 79 | end 80 | 81 | describe '#subscribe_to' do 82 | let(:event_store) { double(:event_store) } 83 | let(:events) { [ItemAdded.new(id: 1), ItemAdded.new(id: 2)] } 84 | let(:subscription_master) { spy(EventSourcery::EventStore::SignalHandlingSubscriptionMaster) } 85 | subject(:event_processor) { 86 | new_event_processor do 87 | processor_name 'my_processor' 88 | process do |event| 89 | @events << event 90 | end 91 | end 92 | } 93 | 94 | before do 95 | allow(event_store).to receive(:subscribe).and_yield(events).once 96 | end 97 | 98 | it 'sets up the tracker' do 99 | expect(tracker).to receive(:setup).with('my_processor') 100 | event_processor.subscribe_to(event_store) 101 | end 102 | 103 | it 'subscribes to the event store from the last processed ID + 1' do 104 | allow(tracker).to receive(:last_processed_event_id).with('my_processor').and_return(2) 105 | expect(event_store).to receive(:subscribe).with(from_id: 3, 106 | event_types: nil, 107 | subscription_master: subscription_master) 108 | event_processor.subscribe_to(event_store, subscription_master: subscription_master) 109 | end 110 | 111 | context 'when processing specific event types' do 112 | subject(:event_processor) { 113 | new_event_processor do 114 | processor_name 'my_processor' 115 | process ItemAdded do 116 | # Noop 117 | end 118 | end 119 | } 120 | 121 | it 'subscribes to the event store for the given types' do 122 | allow(tracker).to receive(:last_processed_event_id).with('my_processor').and_return(2) 123 | expect(event_store).to receive(:subscribe).with(from_id: 3, 124 | event_types: ['item_added'], 125 | subscription_master: subscription_master) 126 | event_processor.subscribe_to(event_store, subscription_master: subscription_master) 127 | end 128 | end 129 | 130 | it 'processes events received on the subscription' do 131 | event_processor.subscribe_to(event_store) 132 | expect(event_processor.events).to eq events 133 | end 134 | 135 | it 'updates the tracker after each event has been processed' do 136 | expect(tracker).to receive(:processed_event).with(event_processor.processor_name, events[0].id) 137 | expect(tracker).to receive(:processed_event).with(event_processor.processor_name, events[1].id) 138 | event_processor.subscribe_to(event_store) 139 | end 140 | 141 | it 'marks the safe shutdown points' do 142 | event_processor.subscribe_to(event_store, subscription_master: subscription_master) 143 | expect(subscription_master).to have_received(:shutdown_if_requested).twice 144 | end 145 | end 146 | 147 | describe '#process' do 148 | context 'using a generic process handler' do 149 | let(:event) { ItemAdded.new } 150 | subject(:event_processor) { 151 | Class.new do 152 | include EventSourcery::EventProcessing::EventStreamProcessor 153 | attr_reader :events 154 | processor_name 'my_processor' 155 | 156 | attr_reader :internal_event_ref 157 | 158 | process ItemAdded do |event| 159 | @internal_event_ref = _event.dup 160 | @events ||= [] 161 | @events << event 162 | end 163 | end.new(tracker: tracker) 164 | } 165 | 166 | context "given an event the processor doesn't care about" do 167 | let(:event) { ItemRemoved.new } 168 | 169 | it 'does not process them' do 170 | event_processor.process(event) 171 | expect(event_processor.events).to be_nil 172 | end 173 | end 174 | 175 | context 'given an event the processor cares about' do 176 | let(:event) { ItemAdded.new } 177 | 178 | it 'processes them' do 179 | event_processor.process(event) 180 | expect(event_processor.events).to eq [event] 181 | end 182 | end 183 | end 184 | 185 | context 'when using specific event handlers' do 186 | subject(:event_processor) { 187 | new_event_processor do 188 | process ItemAdded do |event| 189 | @added_event = event 190 | end 191 | 192 | process ItemRemoved do |event| 193 | @removed_event = event 194 | end 195 | 196 | attr_reader :added_event, :removed_event 197 | end 198 | } 199 | let(:item_added_event) { ItemAdded.new } 200 | let(:item_removed_event) { ItemRemoved.new } 201 | 202 | it 'calls the defined handler' do 203 | event_processor.process(item_added_event) 204 | expect(event_processor.added_event).to eq item_added_event 205 | event_processor.process(item_removed_event) 206 | expect(event_processor.removed_event).to eq item_removed_event 207 | end 208 | 209 | it 'returns the events in processed event types' do 210 | expect(event_processor.processes_event_types).to contain_exactly('item_added', 'item_removed') 211 | end 212 | 213 | context 'processing multiple events in handlers' do 214 | let(:event_processor) { 215 | new_event_processor do 216 | process ItemAdded do |event| 217 | @added_event = event 218 | end 219 | 220 | process ItemAdded, ItemRemoved do |event| 221 | @added_and_removed_events ||= [] 222 | @added_and_removed_events << event 223 | end 224 | 225 | attr_reader :added_and_removed_events, :added_event 226 | end 227 | } 228 | 229 | it 'calls the associated handlers for each event' do 230 | event_processor.process(item_added_event) 231 | event_processor.process(item_removed_event) 232 | expect(event_processor.added_event).to eq item_added_event 233 | expect(event_processor.added_and_removed_events).to eq [item_added_event, item_removed_event] 234 | end 235 | end 236 | 237 | context 'processing events and raise error' do 238 | class FooProcessor 239 | include EventSourcery::EventProcessing::EventStreamProcessor 240 | processor_name 'foo_processor' 241 | 242 | process ItemAdded do |event| 243 | raise 'Something is wrong' 244 | end 245 | end 246 | 247 | let(:event_processor) { FooProcessor.new(tracker: tracker) } 248 | 249 | it 'wraps raised exception with EventProcessingError' do 250 | expect { 251 | event_processor.process(item_added_event) 252 | }.to raise_error { |error| 253 | expect(error).to be_a(EventSourcery::EventProcessingError) 254 | expect(error.event).to eq item_added_event 255 | expect(error.message).to eq <<-EOF.gsub(/^ {14}/, '') 256 | # 257 | # 258 | # 259 | EOF 260 | } 261 | end 262 | end 263 | end 264 | 265 | context 'when attempting to add multiple generic handlers' do 266 | let(:event_processor) do 267 | Class.new do 268 | include EventSourcery::EventProcessing::EventStreamProcessor 269 | 270 | process do 271 | end 272 | 273 | process do 274 | end 275 | end.new 276 | end 277 | 278 | it 'raises an error' do 279 | expect { event_processor }.to raise_error EventSourcery::MultipleCatchAllHandlersDefined, 'Attemping to define multiple catch all event handlers.' 280 | end 281 | end 282 | end 283 | end 284 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/). 7 | 8 | ## [Unreleased] 9 | 10 | ## [1.0.0] - 2023-11-29 11 | 12 | ### Removed 13 | - Removed Event.persisted? as it was potentially misleading ([#235]) 14 | 15 | [#235]: https://github.com/envato/event_sourcery/pull/235 16 | 17 | ## [0.24.0] - 2021-11-18 18 | 19 | ### Added 20 | 21 | - Test against Ruby 3.0 in the CI build ([#229]). 22 | 23 | ### Changed 24 | 25 | - Use GitHub Actions for the CI build instead of Travis CI ([#228]). 26 | - This project now uses `main` as its default branch ([#230]). 27 | - Documentation updated to refer to `main` and links updated accordingly. 28 | 29 | ### Removed 30 | - Remove Ruby 2.3, 2.4 and 2.5 from the CI test matrix ([#232]). 31 | 32 | [#228]: https://github.com/envato/event_sourcery/pull/228 33 | [#229]: https://github.com/envato/event_sourcery/pull/229 34 | [#230]: https://github.com/envato/event_sourcery/pull/230 35 | [#232]: https://github.com/envato/event_sourcery/pull/232 36 | 37 | ## [0.23.1] - 2020-10-02 38 | ### Fixed 39 | - Upgrade development dependency Rake to version 13. This resolves 40 | [CVE-2020-8130](https://github.com/advisories/GHSA-jppv-gw3r-w3q8). 41 | 42 | - Patch `ESPRunner` to gracefully handle terminating subprocesses it did 43 | not start ([#223]). 44 | 45 | - Resolve warnings raised by Ruby 2.7 ([#225]). 46 | 47 | [#223]: https://github.com/envato/event_sourcery/pull/223 48 | [#225]: https://github.com/envato/event_sourcery/pull/225 49 | 50 | ## [0.23.0] - 2019-07-11 51 | ### Added 52 | - Add Ruby 2.6 to the CI test matrix. 53 | - `ESPRunner` supports an `after_subprocess_termination` hook. This optional 54 | initializer argument will will be executed when each child process 55 | terminates. This allows for monitoring and alerts to be configured. 56 | For example, Rollbar: 57 | 58 | ```ruby 59 | EventSourcery::EventProcessing::ESPRunner.new( 60 | event_processors: processors, 61 | event_source: source, 62 | after_subprocess_termination: proc do |processor:, runner:, exit_status:| 63 | if exit_status != 0 64 | Rollbar.error("Processor #{processor.processor_name} "\ 65 | "terminated with exit status #{exit_status}") 66 | end 67 | end 68 | ).start! 69 | ``` 70 | 71 | - `ESPRunner` exposes three new public methods `start_processor`, `shutdown`, 72 | and `shutdown_requested?`. These provide options for handling subprocess 73 | failure/termination. For example, shutting down the `ESPRunner`: 74 | 75 | ```ruby 76 | EventSourcery::EventProcessing::ESPRunner.new( 77 | event_processors: processors, 78 | event_source: source, 79 | after_subprocess_termination: proc do |processor:, runner:, exit_status:| 80 | runner.shutdown 81 | end 82 | ).start! 83 | ``` 84 | 85 | Or restarting the event processor: 86 | 87 | ```ruby 88 | EventSourcery::EventProcessing::ESPRunner.new( 89 | event_processors: processors, 90 | event_source: source, 91 | after_subprocess_termination: proc do |processor:, runner:, exit_status:| 92 | runner.start_processor(processor) unless runner.shutdown_requested? 93 | end 94 | ).start! 95 | ``` 96 | 97 | - `ESPRunner` checks for dead child processes every second. This means we 98 | shouldn't see `[ruby] ` in the process list (ps) when a processor 99 | fails. 100 | - `ESPRunner` logs when child processes die. 101 | - `ESPRunner` logs when sending signals to child processes. 102 | 103 | ### Removed 104 | - Remove Ruby 2.2 from the CI test matrix. 105 | 106 | ## [0.22.0] - 2018-10-04 107 | ### Added 108 | - Log critical exceptions to the application provided block via the new 109 | configuration option ([#209](https://github.com/envato/event_sourcery/pull/209)): 110 | 111 | ```ruby 112 | config.on_event_processor_critical_error = proc do |exception, processor_name| 113 | # report the death of this processor to an error reporting service like Rollbar. 114 | end 115 | ``` 116 | 117 | ## [0.21.0] - 2018-07-02 118 | ### Added 119 | - Graceful shutdown interrupts poll-wait sleep for quicker quitting 120 | ([#207](https://github.com/envato/event_sourcery/pull/207)). 121 | - Added `bug_tracker_uri`, `changelog_uri` and `source_code_uri` to project 122 | metadata ([#205](https://github.com/envato/event_sourcery/pull/205)). 123 | 124 | ### Changed 125 | - Fixed a bug where ESPRunner would raise an error under certain circumstances 126 | ([#203](https://github.com/envato/event_sourcery/pull/203)). 127 | 128 | ## [0.20.0] - 2018-06-21 129 | ### Changed 130 | - Changed signature of `ESPProcess#initialize` to include a default value for `after_fork`. This prevents the 131 | `after_fork` change from 0.19.0 from being a breaking change to external creators of ESPProcess. 132 | - Added more logging when a fatal exception occurs in ESPProcess 133 | 134 | ## [0.19.0] - 2018-06-06 135 | ### Added 136 | 137 | - Allow passing an `after_fork` lambda to `ESPRunner` that is called after each 138 | `ESPProcess` is forked 139 | 140 | ## [0.18.0] - 2018-05-23 141 | 142 | - Allow specifying a subscription batch size 143 | 144 | ## [0.17.0] - 2018-03-22 145 | ### Added 146 | - Allow changing the event class using Event#with 147 | - Allow upcasting events using custom event classes 148 | 149 | ## [0.16.1] - 2018-01-17 150 | - Fixed bug with Sequel gem expecting processes_event_types to be an Array 151 | 152 | ## [0.16.0] - 2018-01-02 153 | ### Added 154 | - Added additional logging for retries to the ExponentialBackoffRetry error handler 155 | - Remove `processes_events` and related methods in favour of `process` class 156 | method. You can no longer override `process` and subscribe to all events. 157 | If you want to subscribe to all events you can call the `process` class 158 | method with no events. 159 | 160 | process do |event| 161 | # all events will be subscribed to 162 | end 163 | 164 | process Foobar do |event| 165 | # Foobar events will be subscribed to 166 | end 167 | 168 | ## [0.15.0] - 2017-11-29 169 | ### Added 170 | - Added in the first version of the yard documentation. 171 | 172 | ### Changed 173 | - Improved EventProcessingError messages 174 | - Fixed typo in constant name `EventSourcery::EventProcessing::ErrorHandlers::ConstantRetry::DEFAULT_RETRY_INTERVAL` 175 | - Fixed typo in constant name `EventSourcery::EventProcessing::ErrorHandlers::ExponentialBackoffRetry::DEFAULT_RETRY_INTERVAL` 176 | - Fixed typo in constant name `EventSourcery::EventProcessing::ErrorHandlers::ExponentialBackoffRetry::MAX_RETRY_INTERVAL` 177 | - Errors of type `Exception` are now logged before being allowed to propagate. 178 | 179 | ## [0.14.0] - 2016-06-21 180 | ### Added 181 | - Added `Event#to_h` method. This returns a hash of the event attributes. 182 | - Added `Event#with` method. This provides a way to create a new event 183 | identical to the old event except for the provided changes. 184 | - `Event#initialize` accepts `aggregate_id` parameter that either is 185 | a strings or responds to `to_str`. 186 | 187 | ## [0.13.0] - 2016-06-16 188 | ### Added 189 | - The core Event class accepts `causation_id` to allow event stores to 190 | add support for tracking causation ids with events. 191 | - The core Memory event store saves the `causation_id` and `correlation_id`. 192 | 193 | ### Changed 194 | - The event store shared RSpec examples specify event stores should save 195 | the `causation_id` and `correlation_id`. 196 | 197 | ### Removed 198 | - The `processing_event` method from the memory tracker. It was intended to 199 | be a mechanism to wrap processing and tracker updates which appears to be 200 | universally unused at this point. 201 | 202 | ## [0.12.0] - 2017-06-01 203 | ### Removed 204 | - Removed usage `#shutdown!` as it should be a private method within custom PollWaiters. 205 | An example of how event_sourcery-postgres has implemented `#shutdown!` can be 206 | found [here](https://github.com/envato/event_sourcery-postgres/pull/5) 207 | 208 | ## [0.11.2] - 2017-05-29 209 | ### Fixed 210 | - Fixed: default poll waiter now implements `shutdown!` 211 | 212 | ## [0.11.1] - 2017-05-29 213 | ### Fixed 214 | - Use `processor.class.name` to set ESP process name 215 | - Convert `processor_name` symbol to string explicitly 216 | 217 | ## [0.11.0] - 2017-05-26 218 | ### Added 219 | - Make Event processing error handler class Configurable 220 | - Add exponential back off retry error handler 221 | 222 | ## [0.10.0] - 2017-05-24 223 | ### Added 224 | - The core Event class accepts `correlation_id` to allow event stores to 225 | add support for tracking correlation IDs with events. 226 | - `Repository#save` for saving aggregate instances. 227 | - Configuration option to define custom event body serializers. 228 | 229 | ### Fixed 230 | - Resolved Sequel deprecation notice when loading events from the Postgres event 231 | store. 232 | 233 | ### Changed 234 | - Aggregates no longer save events directly to an event sink. They must be 235 | passed back to the repository for saving with `repository.save(aggregate)`. 236 | - `AggregateRoot#apply_event` signature has changed from accepting an event or 237 | a hash to accepting an event class followed by what would normally go in the 238 | constructor of the event. 239 | 240 | ### Removed 241 | - Postgres specific code has moved to the [event_sourcery-postgres](https://github.com/envato/event_sourcery-postgres) gem. 242 | Config options for postgres have moved to `EventSourcery::Postgres.config`. 243 | 244 | ## [0.9.0] - 2017-05-02 245 | ### Added 246 | - Add `table_prefix` method to `TableOwner` to declare a table name prefix for 247 | all tables in a projector or reactor. 248 | 249 | ### Changed 250 | - Schema change: the `writeEvents` function has been refactored slightly. 251 | - The `Event` class no longer uses `Virtus.value_object`. 252 | - `AggregateRoot` and `Repository` are namespaced under `EventSourcery` instead 253 | of `EventSourcery::Command`. 254 | - `EventSourcery::Postgres` namespace has been extracted from 255 | `EventSourcery::(EventStore|EventProcessing)::Postgres` in preparation for 256 | moving all Postgres related code into a separate gem. 257 | - An advisory lock has replaced the exclusive table lock used to synchronise 258 | event inserts. 259 | 260 | ### Removed 261 | - EventSourcery no longer depends on Virtus. 262 | - `Command` and `CommandHandler` have been removed. 263 | 264 | [Unreleased]: https://github.com/envato/event_sourcery/compare/v0.24.0...HEAD 265 | [1.0.0]: https://github.com/envato/event_sourcery/compare/v0.24.0...v1.0.0 266 | [0.24.0]: https://github.com/envato/event_sourcery/compare/v0.23.1...v0.24.0 267 | [0.23.1]: https://github.com/envato/event_sourcery/compare/v0.23.0...v0.23.1 268 | [0.23.0]: https://github.com/envato/event_sourcery/compare/v0.22.0...v0.23.0 269 | [0.22.0]: https://github.com/envato/event_sourcery/compare/v0.21.0...v0.22.0 270 | [0.21.0]: https://github.com/envato/event_sourcery/compare/v0.20.0...v0.21.0 271 | [0.20.0]: https://github.com/envato/event_sourcery/compare/v0.19.0...v0.20.0 272 | [0.19.0]: https://github.com/envato/event_sourcery/compare/v0.18.0...v0.19.0 273 | [0.18.0]: https://github.com/envato/event_sourcery/compare/v0.17.0...v0.18.0 274 | [0.17.0]: https://github.com/envato/event_sourcery/compare/v0.16.0...v0.17.0 275 | [0.16.1]: https://github.com/envato/event_sourcery/compare/v0.16.0...v0.16.1 276 | [0.16.0]: https://github.com/envato/event_sourcery/compare/v0.15.0...v0.16.0 277 | [0.15.0]: https://github.com/envato/event_sourcery/compare/v0.14.0...v0.15.0 278 | [0.14.0]: https://github.com/envato/event_sourcery/compare/v0.13.0...v0.14.0 279 | [0.13.0]: https://github.com/envato/event_sourcery/compare/v0.12.0...v0.13.0 280 | [0.12.0]: https://github.com/envato/event_sourcery/compare/v0.11.2...v0.12.0 281 | [0.11.2]: https://github.com/envato/event_sourcery/compare/v0.11.1...v0.11.2 282 | [0.11.1]: https://github.com/envato/event_sourcery/compare/v0.11.0...v0.11.1 283 | [0.11.0]: https://github.com/envato/event_sourcery/compare/v0.10.0...v0.11.0 284 | [0.10.0]: https://github.com/envato/event_sourcery/compare/v0.9.0...v0.10.0 285 | [0.9.0]: https://github.com/envato/event_sourcery/compare/v0.8.0...v0.9.0 286 | -------------------------------------------------------------------------------- /lib/event_sourcery/rspec/event_store_shared_examples.rb: -------------------------------------------------------------------------------- 1 | RSpec.shared_examples 'an event store' do 2 | TestEvent2 = Class.new(EventSourcery::Event) 3 | UserSignedUp = Class.new(EventSourcery::Event) 4 | ItemRejected = Class.new(EventSourcery::Event) 5 | Type1 = Class.new(EventSourcery::Event) 6 | Type2 = Class.new(EventSourcery::Event) 7 | BillingDetailsProvided = Class.new(EventSourcery::Event) 8 | 9 | let(:aggregate_id) { SecureRandom.uuid } 10 | 11 | describe '#sink' do 12 | it 'assigns auto incrementing event IDs' do 13 | event_store.sink(ItemAdded.new(aggregate_id: SecureRandom.uuid)) 14 | event_store.sink(ItemAdded.new(aggregate_id: SecureRandom.uuid)) 15 | event_store.sink(ItemAdded.new(aggregate_id: SecureRandom.uuid)) 16 | events = event_store.get_next_from(1) 17 | expect(events.count).to eq 3 18 | expect(events.map(&:id)).to eq [1, 2, 3] 19 | end 20 | 21 | it 'assigns UUIDs' do 22 | uuid = SecureRandom.uuid 23 | event_store.sink(ItemAdded.new(aggregate_id: SecureRandom.uuid, uuid: uuid)) 24 | event = event_store.get_next_from(1).first 25 | expect(event.uuid).to eq uuid 26 | end 27 | 28 | it 'returns true' do 29 | expect(event_store.sink(ItemAdded.new(aggregate_id: SecureRandom.uuid))).to eq true 30 | end 31 | 32 | it 'serializes the event body' do 33 | time = Time.now 34 | event = ItemAdded.new(aggregate_id: SecureRandom.uuid, body: { 'time' => time }) 35 | expect(event_store.sink(event)).to eq true 36 | expect(event_store.get_next_from(1, limit: 1).first.body).to eq('time' => time.iso8601) 37 | end 38 | 39 | it 'saves the causation_id' do 40 | causation_id = SecureRandom.uuid 41 | event = ItemAdded.new(aggregate_id: SecureRandom.uuid, causation_id: causation_id) 42 | event_store.sink(event) 43 | expect(event_store.get_next_from(1, limit: 1).first.causation_id).to eq(causation_id) 44 | end 45 | 46 | it 'saves the correlation_id' do 47 | correlation_id = SecureRandom.uuid 48 | event = ItemAdded.new(aggregate_id: SecureRandom.uuid, correlation_id: correlation_id) 49 | event_store.sink(event) 50 | expect(event_store.get_next_from(1, limit: 1).first.correlation_id).to eq(correlation_id) 51 | end 52 | 53 | it 'writes multiple events' do 54 | event_store.sink([ItemAdded.new(aggregate_id: aggregate_id, body: {e: 1}), 55 | ItemAdded.new(aggregate_id: aggregate_id, body: {e: 2}), 56 | ItemAdded.new(aggregate_id: aggregate_id, body: {e: 3})]) 57 | events = event_store.get_next_from(1) 58 | expect(events.count).to eq 3 59 | expect(events.map(&:id)).to eq [1, 2, 3] 60 | expect(events.map(&:body)).to eq [{'e' => 1}, {'e' => 2}, {'e' => 3}] 61 | expect(events.map(&:version)).to eq [1, 2, 3] 62 | end 63 | 64 | it 'sets the correct aggregates version' do 65 | event_store.sink([ItemAdded.new(aggregate_id: aggregate_id, body: {e: 1}), 66 | ItemAdded.new(aggregate_id: aggregate_id, body: {e: 2})]) 67 | # this will throw a unique constrain error if the aggregate version was not set correctly ^ 68 | event_store.sink([ItemAdded.new(aggregate_id: aggregate_id, body: {e: 1}), 69 | ItemAdded.new(aggregate_id: aggregate_id, body: {e: 2})]) 70 | events = event_store.get_next_from(1) 71 | expect(events.count).to eq 4 72 | expect(events.map(&:id)).to eq [1, 2, 3, 4] 73 | end 74 | 75 | context 'with no existing aggregate stream' do 76 | it 'saves an event' do 77 | event = TestEvent2.new(aggregate_id: aggregate_id, body: { 'my' => 'data' }) 78 | event_store.sink(event) 79 | events = event_store.get_next_from(1) 80 | expect(events.count).to eq 1 81 | expect(events.first.id).to eq 1 82 | expect(events.first.aggregate_id).to eq aggregate_id 83 | expect(events.first.type).to eq 'test_event2' 84 | expect(events.first.body).to eq({ 'my' => 'data' }) # should we symbolize keys when hydrating events? 85 | end 86 | end 87 | 88 | context 'with an existing aggregate stream' do 89 | before do 90 | event_store.sink(ItemAdded.new(aggregate_id: aggregate_id)) 91 | end 92 | 93 | it 'saves an event' do 94 | event = TestEvent2.new(aggregate_id: aggregate_id, body: { 'my' => 'data' }) 95 | event_store.sink(event) 96 | events = event_store.get_next_from(1) 97 | expect(events.count).to eq 2 98 | expect(events.last.id).to eq 2 99 | expect(events.last.aggregate_id).to eq aggregate_id 100 | expect(events.last.type).to eq :test_event2.to_s # shouldn't you get back what you put in, a symbol? 101 | expect(events.last.body).to eq({ 'my' => 'data' }) # should we symbolize keys when hydrating events? 102 | end 103 | end 104 | 105 | it 'correctly inserts created at times when inserting multiple events atomically' do 106 | time = Time.parse('2016-10-14T00:00:00.646191Z') 107 | event_store.sink([ItemAdded.new(aggregate_id: aggregate_id, created_at: nil), ItemAdded.new(aggregate_id: aggregate_id, created_at: time)]) 108 | created_ats = event_store.get_next_from(0).map(&:created_at) 109 | expect(created_ats.map(&:class)).to eq [Time, Time] 110 | expect(created_ats.last).to eq time 111 | end 112 | 113 | it 'raises an error if the events given are for more than one aggregate' do 114 | expect { 115 | event_store.sink([ItemAdded.new(aggregate_id: aggregate_id), ItemAdded.new(aggregate_id: SecureRandom.uuid)]) 116 | }.to raise_error(EventSourcery::AtomicWriteToMultipleAggregatesNotSupported) 117 | end 118 | end 119 | 120 | describe '#get_next_from' do 121 | it 'gets a subset of events' do 122 | event_store.sink(ItemAdded.new(aggregate_id: aggregate_id)) 123 | event_store.sink(ItemAdded.new(aggregate_id: aggregate_id)) 124 | expect(event_store.get_next_from(1, limit: 1).map(&:id)).to eq [1] 125 | expect(event_store.get_next_from(2, limit: 1).map(&:id)).to eq [2] 126 | expect(event_store.get_next_from(1, limit: 2).map(&:id)).to eq [1, 2] 127 | end 128 | 129 | it 'returns the event as expected' do 130 | event_store.sink(ItemAdded.new(aggregate_id: aggregate_id, body: { 'my' => 'data' })) 131 | event = event_store.get_next_from(1, limit: 1).first 132 | expect(event.aggregate_id).to eq aggregate_id 133 | expect(event.type).to eq 'item_added' 134 | expect(event.body).to eq({ 'my' => 'data' }) 135 | expect(event.created_at).to be_instance_of(Time) 136 | end 137 | 138 | it 'filters by event type' do 139 | event_store.sink(UserSignedUp.new(aggregate_id: aggregate_id)) 140 | event_store.sink(ItemAdded.new(aggregate_id: aggregate_id)) 141 | event_store.sink(ItemAdded.new(aggregate_id: aggregate_id)) 142 | event_store.sink(ItemRejected.new(aggregate_id: aggregate_id)) 143 | event_store.sink(UserSignedUp.new(aggregate_id: aggregate_id)) 144 | events = event_store.get_next_from(1, event_types: ['user_signed_up']) 145 | expect(events.count).to eq 2 146 | expect(events.map(&:id)).to eq [1, 5] 147 | end 148 | end 149 | 150 | describe '#latest_event_id' do 151 | it 'returns the latest event id' do 152 | event_store.sink(ItemAdded.new(aggregate_id: aggregate_id)) 153 | event_store.sink(ItemAdded.new(aggregate_id: aggregate_id)) 154 | expect(event_store.latest_event_id).to eq 2 155 | end 156 | 157 | context 'with no events' do 158 | it 'returns 0' do 159 | expect(event_store.latest_event_id).to eq 0 160 | end 161 | end 162 | 163 | context 'with event type filtering' do 164 | it 'gets the latest event ID for a set of event types' do 165 | event_store.sink(Type1.new(aggregate_id: aggregate_id)) 166 | event_store.sink(Type1.new(aggregate_id: aggregate_id)) 167 | event_store.sink(Type2.new(aggregate_id: aggregate_id)) 168 | 169 | expect(event_store.latest_event_id(event_types: ['type1'])).to eq 2 170 | expect(event_store.latest_event_id(event_types: ['type2'])).to eq 3 171 | expect(event_store.latest_event_id(event_types: ['type1', 'type2'])).to eq 3 172 | end 173 | end 174 | end 175 | 176 | describe '#get_events_for_aggregate_id' do 177 | RSpec.shared_examples 'gets events for a specific aggregate id' do 178 | before do 179 | event_store.sink(ItemAdded.new(aggregate_id: aggregate_id, body: { 'my' => 'body' })) 180 | event_store.sink(ItemAdded.new(aggregate_id: double(to_str: aggregate_id))) 181 | event_store.sink(ItemAdded.new(aggregate_id: SecureRandom.uuid)) 182 | end 183 | 184 | subject(:events) { event_store.get_events_for_aggregate_id(uuid) } 185 | 186 | specify do 187 | expect(events.map(&:id)).to eq([1, 2]) 188 | expect(events.first.aggregate_id).to eq aggregate_id 189 | expect(events.first.type).to eq 'item_added' 190 | expect(events.first.body).to eq({ 'my' => 'body' }) 191 | expect(events.first.created_at).to be_instance_of(Time) 192 | end 193 | end 194 | 195 | context 'when aggregate_id is a string' do 196 | include_examples 'gets events for a specific aggregate id' do 197 | let(:uuid) { aggregate_id } 198 | end 199 | end 200 | 201 | context 'when aggregate_id is convertible to a string' do 202 | include_examples 'gets events for a specific aggregate id' do 203 | let(:uuid) { double(to_str: aggregate_id) } 204 | end 205 | end 206 | end 207 | 208 | describe '#each_by_range' do 209 | before do 210 | (1..21).each do |i| 211 | event_store.sink(ItemAdded.new(aggregate_id: aggregate_id, body: {})) 212 | end 213 | end 214 | 215 | def events_by_range(from_event_id, to_event_id, **args) 216 | [].tap do |events| 217 | event_store.each_by_range(from_event_id, to_event_id, **args) do |event| 218 | events << event 219 | end 220 | end 221 | end 222 | 223 | context "the range doesn't include the latest event ID" do 224 | it 'returns only the events in the range' do 225 | events = events_by_range(1, 20) 226 | expect(events.count).to eq 20 227 | expect(events.map(&:id)).to eq((1..20).to_a) 228 | end 229 | end 230 | 231 | context 'the range includes the latest event ID' do 232 | it 'returns all the events' do 233 | events = events_by_range(1, 21) 234 | expect(events.count).to eq 21 235 | expect(events.map(&:id)).to eq((1..21).to_a) 236 | end 237 | end 238 | 239 | context 'the range exceeds the latest event ID' do 240 | it 'returns all the events' do 241 | events = events_by_range(1, 25) 242 | expect(events.count).to eq 21 243 | expect(events.map(&:id)).to eq((1..21).to_a) 244 | end 245 | end 246 | 247 | context 'the range filters by event type' do 248 | it 'returns only events of the given type' do 249 | expect(events_by_range(1, 21, event_types: ['user_signed_up']).count).to eq 0 250 | expect(events_by_range(1, 21, event_types: ['item_added']).count).to eq 21 251 | end 252 | end 253 | end 254 | 255 | def save_event(expected_version: nil) 256 | event_store.sink( 257 | BillingDetailsProvided.new(aggregate_id: aggregate_id, body: { my_event: 'data' }), 258 | expected_version: expected_version, 259 | ) 260 | end 261 | 262 | def add_event 263 | event_store.sink(ItemAdded.new(aggregate_id: aggregate_id)) 264 | end 265 | 266 | def last_event 267 | event_store.get_next_from(0).last 268 | end 269 | 270 | context 'optimistic concurrency control' do 271 | context "when the aggregate doesn't exist" do 272 | context 'and the expected version is correct - 0' do 273 | it 'saves the event with and sets the aggregate version to version 1' do 274 | save_event(expected_version: 0) 275 | expect(last_event.version).to eq 1 276 | end 277 | end 278 | 279 | context 'and the expected version is incorrect - 1' do 280 | it 'raises a ConcurrencyError' do 281 | expect { 282 | save_event(expected_version: 1) 283 | }.to raise_error(EventSourcery::ConcurrencyError) 284 | end 285 | end 286 | 287 | context 'with no expected version' do 288 | it 'saves the event with and sets the aggregate version to version 1' do 289 | save_event 290 | expect(last_event.version).to eq 1 291 | end 292 | end 293 | end 294 | 295 | context 'when the aggregate exists' do 296 | before do 297 | add_event 298 | end 299 | 300 | context 'with an incorrect expected version - 0' do 301 | it 'raises a ConcurrencyError' do 302 | expect { 303 | save_event(expected_version: 0) 304 | }.to raise_error(EventSourcery::ConcurrencyError) 305 | end 306 | end 307 | 308 | context 'with a correct expected version - 1' do 309 | it 'saves the event with and sets the aggregate version to version 2' do 310 | save_event 311 | expect(last_event.version).to eq 2 312 | end 313 | end 314 | 315 | context 'with no aggregate version' do 316 | it 'automatically sets the version on the event and aggregate' do 317 | save_event 318 | expect(last_event.version).to eq 2 319 | end 320 | end 321 | end 322 | 323 | it 'allows overriding the created_at timestamp for events' do 324 | time = Time.parse('2016-10-14T00:00:00.646191Z') 325 | event_store.sink(BillingDetailsProvided.new(aggregate_id: aggregate_id, 326 | body: { my_event: 'data' }, 327 | created_at: time)) 328 | expect(last_event.created_at).to eq time 329 | end 330 | 331 | it "sets a created_at time when one isn't provided in the event" do 332 | event_store.sink(BillingDetailsProvided.new(aggregate_id: aggregate_id, 333 | body: { my_event: 'data' })) 334 | expect(last_event.created_at).to be_instance_of(Time) 335 | end 336 | end 337 | end 338 | -------------------------------------------------------------------------------- /spec/event_sourcery/event_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe EventSourcery::Event do 2 | let(:aggregate_id) { 'aggregate_id' } 3 | let(:type) { 'type' } 4 | let(:version) { 1 } 5 | let(:body) do 6 | { 7 | symbol: "value", 8 | } 9 | end 10 | let(:uuid) { SecureRandom.uuid } 11 | 12 | describe '#initialize' do 13 | subject(:initializer) { described_class.new(aggregate_id: aggregate_id, type: type, body: body, version: version) } 14 | 15 | before do 16 | allow(EventSourcery::EventBodySerializer).to receive(:serialize) 17 | end 18 | 19 | it 'serializes event body' do 20 | expect(EventSourcery::EventBodySerializer).to receive(:serialize).with(body) 21 | initializer 22 | end 23 | 24 | it 'assigns a uuid if one is not given' do 25 | allow(SecureRandom).to receive(:uuid).and_return(uuid) 26 | expect(initializer.uuid).to eq uuid 27 | end 28 | 29 | it 'assigns given uuids' do 30 | uuid = SecureRandom.uuid 31 | expect(described_class.new(uuid: uuid).uuid).to eq uuid 32 | end 33 | 34 | it 'assigns a given correlation_id' do 35 | uuid = SecureRandom.uuid 36 | event = described_class.new(correlation_id: uuid) 37 | expect(event.correlation_id).to eq uuid 38 | end 39 | 40 | it 'has a nil correlation_id if none is given' do 41 | event = described_class.new 42 | expect(event.correlation_id).to be_nil 43 | end 44 | 45 | it 'assigns a given causation_id' do 46 | uuid = SecureRandom.uuid 47 | event = described_class.new(causation_id: uuid) 48 | expect(event.causation_id).to eq uuid 49 | end 50 | 51 | it 'has a nil causation_id if none is given' do 52 | event = described_class.new 53 | expect(event.causation_id).to be_nil 54 | end 55 | 56 | context 'event body is nil' do 57 | let(:body) { nil } 58 | 59 | it 'skips serialization of event body' do 60 | expect(EventSourcery::EventBodySerializer).to_not receive(:serialize) 61 | initializer 62 | end 63 | end 64 | 65 | context 'given version is a long string' do 66 | let(:version) { '1' * 20 } 67 | 68 | it 'version type is coerced to an integer value, bignum style' do 69 | expect(initializer.version).to eq(11_111_111_111_111_111_111) 70 | end 71 | end 72 | end 73 | 74 | describe '.type' do 75 | let(:serializer) { double } 76 | 77 | before do 78 | allow(EventSourcery.config).to receive(:event_type_serializer).and_return(serializer) 79 | allow(serializer).to receive(:serialize).and_return('serialized') 80 | end 81 | 82 | it 'delegates to the configured event type serializer' do 83 | ItemAdded.type 84 | expect(serializer).to have_received(:serialize).with(ItemAdded) 85 | end 86 | 87 | it 'returns the serialized type' do 88 | expect(ItemAdded.type).to eq('serialized') 89 | end 90 | 91 | context 'when the event is EventSourcery::Event' do 92 | it 'returns nil' do 93 | expect(EventSourcery::Event.type).to be_nil 94 | end 95 | end 96 | end 97 | 98 | describe '#type' do 99 | before do 100 | allow(EventSourcery::Event).to receive(:type).and_return(type) 101 | end 102 | 103 | context 'when the event class type is nil' do 104 | let(:type) { nil } 105 | 106 | it 'uses the provided type' do 107 | event = EventSourcery::Event.new(type: 'blah') 108 | expect(event.type).to eq 'blah' 109 | end 110 | end 111 | 112 | context 'when the event class type is not nil' do 113 | let(:type) { 'ItemAdded' } 114 | 115 | it "can't be overridden with the provided type" do 116 | event = EventSourcery::Event.new(type: 'blah') 117 | expect(event.type).to eq 'ItemAdded' 118 | end 119 | end 120 | end 121 | 122 | describe '#with' do 123 | subject(:with) { original_event.with(**changes) } 124 | 125 | let(:original_event) { EventSourcery::Event.new(**original_attributes) } 126 | let(:original_attributes) do 127 | { 128 | id: 73, 129 | uuid: SecureRandom.uuid, 130 | aggregate_id: SecureRandom.uuid, 131 | type: 'type', 132 | body: { 'attr' => 'value' }, 133 | version: 89, 134 | created_at: Time.now.utc, 135 | correlation_id: SecureRandom.uuid, 136 | causation_id: SecureRandom.uuid, 137 | } 138 | end 139 | let(:changes) do 140 | { 141 | causation_id: SecureRandom.uuid, 142 | } 143 | end 144 | 145 | it 'returns a new event' do 146 | expect(with).to_not be(original_event) 147 | end 148 | 149 | it 'makes the changes to the new event' do 150 | changes.each do |attribute, value| 151 | expect(with.send(attribute)).to eq(value) 152 | end 153 | end 154 | 155 | it 'maintains the original, unchanged values on the new event' do 156 | original_attributes.each do |attribute, value| 157 | expect(with.send(attribute)).to(eq(value)) unless changes.include?(attribute) 158 | end 159 | end 160 | 161 | it 'does not mutate the original event' do 162 | original_attributes.each do |attribute, value| 163 | expect(original_event.send(attribute)).to(eq(value)) 164 | end 165 | end 166 | 167 | it 'allows changing the event class' do 168 | new_event = original_event.with(event_class: ItemAdded) 169 | 170 | expect(new_event).to be_a ItemAdded 171 | expect(new_event.type).to eq 'item_added' 172 | end 173 | 174 | it 'allows changing the event class of a typed event' do 175 | original_event = ItemRemoved.new 176 | new_event = original_event.with(event_class: ItemAdded) 177 | 178 | expect(new_event).to be_a ItemAdded 179 | expect(new_event.type).to eq 'item_added' 180 | end 181 | 182 | it 'allows changing the event type' do 183 | original_event = EventSourcery::Event.new(type: 'item_removed') 184 | 185 | new_event = original_event.with(type: 'item_added') 186 | expect(new_event.type).to eq 'item_added' 187 | end 188 | 189 | it 'errors when attempting to change the type of a typed event' do 190 | original_event = ItemRemoved.new(type: 'item_removed') 191 | 192 | expect { 193 | original_event.with(type: 'item_added') 194 | }.to raise_error EventSourcery::Error, 'When using typed events change the type by changing the event class.' 195 | end 196 | end 197 | 198 | describe '#to_h' do 199 | %i[id uuid aggregate_id type body version created_at correlation_id causation_id].each do |attribute| 200 | it "includes #{attribute}" do 201 | value = %i[id version].include?(attribute) ? 42 : '42' 202 | event = EventSourcery::Event.new(attribute => value) 203 | expect(event.to_h).to include(attribute => value) 204 | end 205 | end 206 | end 207 | 208 | describe '#hash' do 209 | subject(:hash) { event.hash } 210 | 211 | context 'given an Event with UUID' do 212 | let(:event) { EventSourcery::Event.new(uuid: event_uuid) } 213 | let(:event_uuid) { SecureRandom.uuid } 214 | 215 | it { should be_an Integer } 216 | 217 | context 'compared to an Event with same UUID' do 218 | let(:other) { EventSourcery::Event.new(uuid: event_uuid) } 219 | it { should eq other.hash } 220 | end 221 | 222 | context 'compared to an Event with same UUID (uppercase)' do 223 | let(:other) { EventSourcery::Event.new(uuid: event_uuid.upcase) } 224 | it { should eq other.hash } 225 | end 226 | 227 | context 'compared to an event with different UUID' do 228 | let(:other) { EventSourcery::Event.new(uuid: SecureRandom.uuid) } 229 | it { should_not eq other.hash } 230 | end 231 | 232 | context 'compared to an event without UUID' do 233 | let(:other) { EventSourcery::Event.new(uuid: nil) } 234 | it { should_not eq other.hash } 235 | end 236 | 237 | context 'compared to an ItemAdded event with same UUID' do 238 | let(:other) { ItemAdded.new(uuid: event_uuid) } 239 | it { should_not eq other.hash } 240 | end 241 | end 242 | 243 | context 'given an Event without UUID' do 244 | let(:event) { EventSourcery::Event.new(uuid: nil) } 245 | 246 | it { should be_an Integer } 247 | 248 | context 'compared to an Event without UUID' do 249 | let(:other) { EventSourcery::Event.new(uuid: nil) } 250 | it { should eq other.hash } 251 | end 252 | 253 | context 'compared to an event with UUID' do 254 | let(:other) { EventSourcery::Event.new(uuid: SecureRandom.uuid) } 255 | it { should_not eq other.hash } 256 | end 257 | end 258 | end 259 | 260 | describe '#eql?' do 261 | subject(:eql?) { event.eql?(other) } 262 | 263 | context 'given an Event with UUID' do 264 | let(:event) { EventSourcery::Event.new(uuid: event_uuid) } 265 | let(:event_uuid) { SecureRandom.uuid } 266 | 267 | context 'compared to itself' do 268 | let(:other) { event } 269 | it { should be true } 270 | end 271 | 272 | context 'compared to an Event with same UUID' do 273 | let(:other) { EventSourcery::Event.new(uuid: event_uuid) } 274 | it { should be true } 275 | end 276 | 277 | context 'compared to an Event with same UUID (uppercase)' do 278 | let(:other) { EventSourcery::Event.new(uuid: event_uuid.upcase) } 279 | it { should be true } 280 | end 281 | 282 | context 'compared to an event with different UUID' do 283 | let(:other) { EventSourcery::Event.new(uuid: SecureRandom.uuid) } 284 | it { should be false } 285 | end 286 | 287 | context 'compared to an event without UUID' do 288 | let(:other) { EventSourcery::Event.new(uuid: nil) } 289 | it { should be false } 290 | end 291 | 292 | context 'compared to an ItemAdded event with same UUID' do 293 | let(:other) { ItemAdded.new(uuid: event_uuid) } 294 | it { should be false } 295 | end 296 | end 297 | 298 | context 'given an Event without UUID' do 299 | let(:event) { EventSourcery::Event.new(uuid: nil) } 300 | 301 | context 'compared to itself' do 302 | let(:other) { event } 303 | it { should be true } 304 | end 305 | 306 | context 'compared to an Event without UUID' do 307 | let(:other) { EventSourcery::Event.new(uuid: nil) } 308 | it { should be true } 309 | end 310 | 311 | context 'compared to an event with UUID' do 312 | let(:other) { EventSourcery::Event.new(uuid: SecureRandom.uuid) } 313 | it { should be false } 314 | end 315 | end 316 | end 317 | 318 | describe '#<' do 319 | subject(:<) { event < other } 320 | 321 | context 'given an Event with id 2' do 322 | let(:event) { EventSourcery::Event.new(id: 2) } 323 | 324 | context 'compared to itself' do 325 | let(:other) { event } 326 | it { should be false } 327 | end 328 | 329 | context 'compared to an ItemAdded event with id 1' do 330 | let(:other) { ItemAdded.new(id: 1) } 331 | it { should be false } 332 | end 333 | 334 | context 'compared to an ItemAdded event with id 2' do 335 | let(:other) { ItemAdded.new(id: 2) } 336 | it { should be false } 337 | end 338 | 339 | context 'compared to an ItemAdded event with id 3' do 340 | let(:other) { ItemAdded.new(id: 3) } 341 | it { should be true } 342 | end 343 | 344 | context 'compared to an ItemAdded event without id' do 345 | let(:other) { ItemAdded.new(id: nil) } 346 | 347 | it 'raises an ArgumentError' do 348 | expect { subject }.to raise_error(ArgumentError) 349 | end 350 | end 351 | 352 | context 'compared to a non-event' do 353 | let(:other) { 3 } 354 | 355 | it 'raises an ArgumentError' do 356 | expect { subject }.to raise_error(ArgumentError) 357 | end 358 | end 359 | end 360 | end 361 | 362 | describe '#<=' do 363 | subject(:<=) { event <= other } 364 | 365 | context 'given an Event with id 2' do 366 | let(:event) { EventSourcery::Event.new(id: 2) } 367 | 368 | context 'compared to itself' do 369 | let(:other) { event } 370 | it { should be true } 371 | end 372 | 373 | context 'compared to an ItemAdded event with id 1' do 374 | let(:other) { ItemAdded.new(id: 1) } 375 | it { should be false } 376 | end 377 | 378 | context 'compared to an ItemAdded event with id 2' do 379 | let(:other) { ItemAdded.new(id: 2) } 380 | it { should be true } 381 | end 382 | 383 | context 'compared to an ItemAdded event with id 3' do 384 | let(:other) { ItemAdded.new(id: 3) } 385 | it { should be true } 386 | end 387 | 388 | context 'compared to an ItemAdded event without id' do 389 | let(:other) { ItemAdded.new(id: nil) } 390 | 391 | it 'raises an ArgumentError' do 392 | expect { subject }.to raise_error(ArgumentError) 393 | end 394 | end 395 | 396 | context 'compared to a non-event' do 397 | let(:other) { 3 } 398 | 399 | it 'raises an ArgumentError' do 400 | expect { subject }.to raise_error(ArgumentError) 401 | end 402 | end 403 | end 404 | end 405 | 406 | describe '#==' do 407 | subject(:==) { event == other } 408 | 409 | context 'given an Event with id 2' do 410 | let(:event) { EventSourcery::Event.new(id: 2) } 411 | 412 | context 'compared to itself' do 413 | let(:other) { event } 414 | it { should be true } 415 | end 416 | 417 | context 'compared to an ItemAdded event with id 1' do 418 | let(:other) { ItemAdded.new(id: 1) } 419 | it { should be false } 420 | end 421 | 422 | context 'compared to an ItemAdded event with id 2' do 423 | let(:other) { ItemAdded.new(id: 2) } 424 | it { should be true } 425 | end 426 | 427 | context 'compared to an ItemAdded event with id 3' do 428 | let(:other) { ItemAdded.new(id: 3) } 429 | it { should be false } 430 | end 431 | 432 | context 'compared to a non-event' do 433 | let(:other) { 3 } 434 | it { should be false } 435 | end 436 | end 437 | 438 | context 'given an Event without id' do 439 | let(:event) { EventSourcery::Event.new(id: nil) } 440 | 441 | context 'compared to itself' do 442 | let(:other) { event } 443 | it { should be true } 444 | end 445 | 446 | context 'compared to an ItemAdded event without id' do 447 | let(:other) { ItemAdded.new(id: nil) } 448 | it { should be true } 449 | end 450 | 451 | context 'compared to an ItemAdded event with id 1' do 452 | let(:other) { ItemAdded.new(id: 1) } 453 | it { should be false } 454 | end 455 | end 456 | end 457 | 458 | describe '#<=' do 459 | subject(:<=) { event <= other } 460 | 461 | context 'given an ItemRemoved event with id 2' do 462 | let(:event) { ItemRemoved.new(id: 2) } 463 | 464 | context 'compared to itself' do 465 | let(:other) { event } 466 | it { should be true } 467 | end 468 | 469 | context 'compared to an ItemAdded event with id 1' do 470 | let(:other) { ItemAdded.new(id: 1) } 471 | it { should be false } 472 | end 473 | 474 | context 'compared to an ItemAdded event with id 2' do 475 | let(:other) { ItemAdded.new(id: 2) } 476 | it { should be true } 477 | end 478 | 479 | context 'compared to an ItemAdded event with id 3' do 480 | let(:other) { ItemAdded.new(id: 3) } 481 | it { should be true } 482 | end 483 | 484 | context 'compared to an ItemAdded event without id' do 485 | let(:other) { ItemAdded.new(id: nil) } 486 | 487 | it 'raises an ArgumentError' do 488 | expect { subject }.to raise_error(ArgumentError) 489 | end 490 | end 491 | 492 | context 'compared to a non-event' do 493 | let(:other) { 3 } 494 | 495 | it 'raises an ArgumentError' do 496 | expect { subject }.to raise_error(ArgumentError) 497 | end 498 | end 499 | end 500 | end 501 | 502 | describe '#<' do 503 | subject(:<) { event < other } 504 | 505 | context 'given an Event with id 2' do 506 | let(:event) { EventSourcery::Event.new(id: 2) } 507 | 508 | context 'compared to itself' do 509 | let(:other) { event } 510 | it { should be false } 511 | end 512 | 513 | context 'compared to an ItemAdded event with id 1' do 514 | let(:other) { ItemAdded.new(id: 1) } 515 | it { should be false } 516 | end 517 | 518 | context 'compared to an ItemAdded event with id 2' do 519 | let(:other) { ItemAdded.new(id: 2) } 520 | it { should be false } 521 | end 522 | 523 | context 'compared to an ItemAdded event with id 3' do 524 | let(:other) { ItemAdded.new(id: 3) } 525 | it { should be true } 526 | end 527 | 528 | context 'compared to an ItemAdded event without id' do 529 | let(:other) { ItemAdded.new(id: nil) } 530 | 531 | it 'raises an ArgumentError' do 532 | expect { subject }.to raise_error(ArgumentError) 533 | end 534 | end 535 | 536 | context 'compared to a non-event' do 537 | let(:other) { 3 } 538 | 539 | it 'raises an ArgumentError' do 540 | expect { subject }.to raise_error(ArgumentError) 541 | end 542 | end 543 | end 544 | end 545 | end 546 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EventSourcery 2 | 3 | [![Build Status](https://github.com/envato/event_sourcery/workflows/tests/badge.svg?branch=main)](https://github.com/envato/event_sourcery/actions?query=workflow%3Atests+branch%3Amain) 4 | 5 | A framework for building event sourced, CQRS applications. 6 | 7 | **Table of Contents** 8 | 9 | - [Development Status](#development-status) 10 | - [Goals](#goals) 11 | - [Related Repositories](#related-repositories) 12 | - [Getting Started](#getting-started) 13 | - [Configuration](#configuration) 14 | - [Development](#development) 15 | - [Dependencies](#dependencies) 16 | - [Running the Test Suite](#running-the-test-suite) 17 | - [Release](#release) 18 | - [Core Concepts](#core-concepts) 19 | - [Tour of an EventSourcery Web Application](#tour-of-an-eventsourcery-web-application) 20 | - [Events](#events) 21 | - [The Event Store](#the-event-store) 22 | - [Storing Events](#storing-events) 23 | - [Reading Events](#reading-events) 24 | - [Aggregates and Command Handling](#aggregates-and-command-handling) 25 | - [Event Processing](#event-processing) 26 | - [Event Stream Processors](#event-stream-processors) 27 | - [Projectors](#projectors) 28 | - [Reactors](#reactors) 29 | - [Running Multiple ESPs](#running-multiple-esps) 30 | - [Typical Flow of State in an EventSourcery Application](#typical-flow-of-state-in-an-eventsourcery-application) 31 | - [1. Handling a Command](#1-handling-a-command) 32 | - [2. Updating a Projection](#2-updating-a-projection) 33 | - [3. Handling a Query](#3-handling-a-query) 34 | 35 | ## Development Status 36 | 37 | [![Build Status](https://github.com/envato/event_sourcery/workflows/tests/badge.svg?branch=main)](https://github.com/envato/event_sourcery/actions?query=branch%3Amain) 38 | 39 | Event Sourcery is in production use at [Envato](http://envato.com). 40 | 41 | ## Goals 42 | 43 | The goal of EventSourcery is to make it easier to build event sourced, CQRS applications. 44 | 45 | The hope is that by using EventSourcery you can focus on modeling your domain with aggregates, commands, and events; and not worry about stitching together application plumbing. 46 | 47 | ## Related Repositories 48 | 49 | - EventSourcery's Postgres-based event store implementation: [event_sourcery-postgres](https://github.com/envato/event_sourcery-postgres). 50 | - Example EventSourcery application: [event_sourcery_todo_app](https://github.com/envato/event_sourcery_todo_app). 51 | - An opinionated CLI tool for building event sourced Ruby services with EventSourcery: [event_sourcery_generators](https://github.com/envato/event_sourcery_generators). 52 | 53 | ## Getting Started 54 | 55 | The [example EventSourcery application](https://github.com/envato/event_sourcery_todo_app) is intended to illustrate concepts in EventSourcery, how they relate to each other, and how to use them in practice. If you'd like a succinct look at the library in practice take a look at that. 56 | 57 | Otherwise you will generally need to add both event_sourcery and [event_sourcery-postgres](https://github.com/envato/event_sourcery-postgres) to your application. 58 | 59 | If Event Sourcing or CQRS is a new concept to you, we highly recommend you watch [An In-Depth Look at Event Sourcing With CQRS](https://www.youtube.com/watch?v=EqpalkqJD8M&t=2680s). It explores some of the theory behind both Event Sourcing & CQRS and will help you better understand the building blocks of the Event Sourcery framework. 60 | 61 | ## Configuration 62 | 63 | There are several ways to configure EventSourcery to your liking. The following presents some examples: 64 | 65 | ```ruby 66 | EventSourcery.configure do |config| 67 | # Add custom reporting of errors occurring during event processing. 68 | # One might set up an error reporting service like Rollbar here. 69 | config.on_event_processor_error = proc { |exception, processor_name| … } 70 | config.on_event_processor_critical_error = proc { |exception, processor_name| … } 71 | 72 | # Enable EventSourcery logging. 73 | config.logger = Logger.new('logs/my_event_sourcery_app.log') 74 | 75 | # Customize how event body attributes are serialized 76 | config.event_body_serializer 77 | .add(BigDecimal) { |decimal| decimal.to_s('F') } 78 | 79 | # Config how your want to handle event processing errors 80 | config.error_handler_class = EventSourcery::EventProcessing::ErrorHandlers::ExponentialBackoffRetry 81 | end 82 | ``` 83 | 84 | ## Development 85 | 86 | ### Dependencies 87 | 88 | - Ruby 89 | 90 | ### Running the Test Suite 91 | 92 | Run the `setup` script, inside the project directory to install the gem dependencies and create the test database (if it is not already created). 93 | ```bash 94 | ./bin/setup 95 | ``` 96 | 97 | Then you can run the test suite with rspec: 98 | ```bash 99 | bundle exec rspec 100 | ``` 101 | 102 | ### Release 103 | 104 | To release a new version: 105 | 106 | 1. Update the version number in `lib/event_sourcery/version.rb` 107 | 2. Add the new version with release notes to CHANGELOG.md 108 | 3. Get these changes onto main via the normal PR process 109 | 4. Run `bundle exec rake release`, this will create a git tag for the 110 | version, push tags up to GitHub, and package the code in a `.gem` file. 111 | 112 | ## Core Concepts 113 | 114 | Not sure what Event Sourcing (ES), Command Query Responsibility Segregation (CQRS), or even Domain-Driven Design (DDD) are? Here are a few links to get you started: 115 | 116 | - [CQRS and Event Sourcing Talk](https://www.youtube.com/watch?v=JHGkaShoyNs) - by Greg Young at Code on the Beach 2014 117 | - [DDD/CQRS Google Group](https://groups.google.com/forum/#!forum/dddcqrs) - from people new to the concepts to old hands 118 | - [DDD Weekly Newsletter](https://buildplease.com/pages/dddweekly/) - a weekly digest of what's happening in the community 119 | - [Domain-Driven Design](https://www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215) - the definitive guide 120 | - [Greg Young's Blog](https://goodenoughsoftware.net) - a (the?) lead proponent of all things Event Sourcing 121 | 122 | ### Tour of an EventSourcery Web Application 123 | 124 | Below is a high level view of a CQRS, event-sourced web application built using EventSourcery. The components marked with `*` can be created using building blocks provided by EventSourcery. Keep on reading and we'll describe each of the concepts illustrated. 125 | 126 | ``` 127 | ┌─────────────┐ ┌─────────────┐ 128 | │ │ │ │ 129 | │ Client │ │ Client │ 130 | │ │ │ │ 131 | └─────────────┘ └─────────────┘ 132 | │ │ 133 | Issue Command Issue Query 134 | │ │ 135 | ┌───────┴──────────────────────────────────────┴─────────┐ 136 | │ Web Layer │ 137 | └───────┬──────────────────────────────────────┬─────────┘ 138 | │ │ 139 | ▼ ▼ 140 | ┌─────────────┐ ┌─────────────┐ 141 | │ Command │ │Query Handler│ 142 | │ Handler │ │ │ 143 | └─────────────┘ └─────────────┘ 144 | │ │ 145 | ▼ ┌───────▼─────┐ 146 | ┌─────────────┐ │┌────────────┴┐ 147 | │ * Aggregate │ ││* Projection │ 148 | │ │ └┤ │ 149 | └─────────────┘ └─────────────┘ 150 | │ ▲ 151 | │ │ 152 | │ Update Projection 153 | │ │ 154 | Emit Event ┌─────────────┐ 155 | │ │┌────────────┴┐ 156 | │ ││ * Projector │ 157 | ▼ └┤ │ 158 | ┌─────────────┐ └─────────────┘ 159 | │* Event Store│ Process ▲ 160 | ┌─▶│ │────────Event───────────────────┘ 161 | │ └─────────────┘ 162 | │ │ 163 | │ Process ┌─────────────┐ 164 | │ Event │┌────────────┴┐ ┌ ─ ─ ─ ─ ─ ─ ┐ 165 | │ └───────▶││ * Reactor │ External 166 | │ └┤ │───Trigger ───▶│ System │ 167 | │ └─────────────┘ Behaviour ─ ─ ─ ─ ─ ─ ─ 168 | │ │ 169 | │ │ 170 | └────────Emit Event────────┘ 171 | 172 | ``` 173 | 174 | ### Events 175 | 176 | Events are value objects that record something of meaning in the domain. Think of a sequence of events as a time series of immutable domain facts. Together they form the source of truth for our application's state. 177 | 178 | Events are targeted at an aggregate via an `aggregate_id` and have the following attributes. 179 | 180 | ```ruby 181 | module EventSourcery 182 | class Event 183 | attr_reader \ 184 | :id, # Sequence number 185 | :uuid, # Unique ID 186 | :aggregate_id, # ID of aggregate the event pertains to 187 | :type, # type of the event 188 | :body, # the payload (a hash) 189 | :version, # Version of the aggregate 190 | :created_at, # Created at date 191 | :correlation_id # Correlation ID for tracing purposes 192 | 193 | # ... 194 | end 195 | end 196 | ``` 197 | 198 | You can define events in your domain as follows. 199 | 200 | ```ruby 201 | TodoAdded = Class.new(EventSourcery::Event) 202 | 203 | # An example instance. 204 | # #"My task"}, 210 | # @version=1, 211 | # @created_at=2017-06-14 11:50:32 UTC, 212 | # @correlation_id="b4d1e31d-9d1b-4ea1-a685-57936ce65a80"> 213 | ``` 214 | 215 | ### The Event Store 216 | 217 | The event store is a persistent store of events. 218 | 219 | EventSourcery currently supports a Postgres-based event store via the [event_sourcery-postgres gem](https://github.com/envato/event_sourcery-postgres). 220 | 221 | For more information about the `EventStore` API refer to [the postgres event store](https://github.com/envato/event_sourcery-postgres/blob/HEAD/lib/event_sourcery/postgres/event_store.rb) or the [in memory event store in this repo](lib/event_sourcery/memory/event_store.rb) 222 | 223 | #### Storing Events 224 | 225 | Naturally, it provides the ability to store events. The event store is append-only and immutable. The events in the store form a time-ordered sequence which can be viewed as a stream of events. 226 | 227 | `EventStore` clients can optionally provide an expected version of event when saving to the store. This provides a mechanism for `EventStore` clients to effectively serialise the processing they perform against an instance of an aggregate. 228 | 229 | When used in this fashion the event store can be thought of as an event sink. 230 | 231 | #### Reading Events 232 | 233 | The `EventStore` also allows clients to read events. Clients can poll the store for events of specific types after a specific event ID. They can also subscribe to the event store to be notified when new events are added to the event store that match the above criteria. 234 | 235 | When used in this fashion the event store can be thought of as an event source. 236 | 237 | ### Aggregates and Command Handling 238 | 239 | > An aggregate is a cluster of domain objects that can be treated as a single unit. Every transaction is scoped to a single aggregate. An aggregate will have one of its component objects be the aggregate root. Any references from outside the aggregate should only go to the aggregate root. The root can thus ensure the integrity of the aggregate as a whole. 240 | > 241 | > — [DDD Aggregate](http://martinfowler.com/bliki/DDD_Aggregate.html) 242 | 243 | Clients execute domain transactions against the system by issuing commands against aggregate roots. The result of these commands is new events being saved to the event store. 244 | 245 | A typical EventSourcery application will have one or more aggregate roots with multiple commands. 246 | 247 | ### Event Processing 248 | 249 | A central part of EventSourcery is the processing of events in the store. EventSourcery provides the Event Stream Processor abstraction to support this. 250 | 251 | ``` 252 | ┌─────────────┐ Subscribe to the event store 253 | │Event Stream │ and take some action. Tracks 254 | │ Processor │◀─ ─ ─ ─ ─its position in the stream in 255 | │ │ a way that suits its needs. 256 | └─────────────┘ 257 | ▲ 258 | ┌────────┴───────────┐ 259 | │ │ 260 | │ │ 261 | ┌─────────────┐ ┌─────────────┐ 262 | Listens for events and takes │ │ │ │ Listens for events and 263 | action. Actions include ─▶│ Reactor │ │ Projector │◀─ ┐ projects data into a 264 | emitting new events into the ─ ┘ │ │ │ │ ─ ─ projection. 265 | store and/or triggering side └─────────────┘ └─────────────┘ 266 | effects in the world. 267 | ``` 268 | 269 | A typical EventSourcery application will have multiple projectors and reactors running as background processes. 270 | 271 | #### Event Stream Processors 272 | 273 | Event Stream Processors (ESPs) subscribe to an event store. They read events from the event store and take some action. 274 | 275 | When newly created, an ESP will process the event stream from the beginning. When catching up like this an ESP can process events in batches (currently set to 1,000 events). This allows them to optimise processing as desired. 276 | 277 | ESPs track the position in the event stream that they've processed in a way that suits them. This allows for them to optimise transaction handling in the case where they are catching up for example. 278 | 279 | #### Projectors 280 | 281 | A Projector is an EventStreamProcessor that listens for events and projects data into a projection. These projections are generally consumed on the read side of the CQRS world. 282 | 283 | Projectors tend to be built for specific read-side needs and are generally specific to a single read case. 284 | 285 | Modifying a projection is achieved by creating a new projector. 286 | 287 | #### Reactors 288 | 289 | A Reactor is an EventStreamProcessor that listens to events and emits events back into the store and/or trigger side effects in the world. 290 | 291 | They typically record any external side effects they've triggered as events in the store. 292 | 293 | Reactors can be used to build [process managers or sagas](https://msdn.microsoft.com/en-us/library/jj591569.aspx). 294 | 295 | #### Running Multiple ESPs 296 | 297 | An EventSourcery application will typically have multiple ESPs running. EventSourcery provides a class called [ESPRunner](lib/event_sourcery/event_processing/esp_runner.rb) which can be used to run ESPs. It runs each ESP in a forked child process so each ESP can process the event store independently. You can find an example in [event_sourcery_todo_app](https://github.com/envato/event_sourcery_todo_app/blob/HEAD/Rakefile). 298 | 299 | Note that you may instead choose to run each ESP in their own process directly. The coordination of this is not currently provided by EventSourcery. 300 | 301 | ### Typical Flow of State in an EventSourcery Application 302 | 303 | Below we see the typical flow of state in an EventSourcery application (arrows indicate data flow). Note that steps 1 and 2 are not synchronous. This means EventSourcery applications need to embrace [eventual consistency](https://en.wikipedia.org/wiki/Eventual_consistency). 304 | 305 | ``` 306 | 307 | 1. Issue Command │ 2. Update Projection │ 3. Issue Query 308 | 309 | │ │ 310 | │ ▲ 311 | │ │ │ │ 312 | │ │ 313 | │ │ │ F. Handle 314 | B. Handle Query 315 | Command │ │ │ 316 | │ │ 317 | │ │ │ │ 318 | ▼ ┌─────────────┐ │ 319 | ┌─────────────┐ │ │ │ │ ┌─────────────┐ 320 | │ │ ┌───────▶│ Projector │ │ │ 321 | ┌─▶│ Aggregate │ │ │ │ │ │ │Query Handler│ 322 | │ │ │ │ └─────────────┘ │ │ 323 | │ └─────────────┘ │ D. Read │ │ └─────────────┘ 324 | │ │ event E. Update ▲ 325 | A. Load C. Emit │ │ Projection │ │ 326 | state from Event │ │ G. Read 327 | events │ │ │ │ │ Projection 328 | │ ▼ │ ▼ │ 329 | │ ┌─────────────┐ │ │ ┌─────────────┐ │ │ 330 | │ │ │ │ │ │ │ 331 | └──│ Event Store │───┼───┘ │ Projection │────┼───────────────┘ 332 | │ │ │ │ 333 | └─────────────┘ │ └─────────────┘ │ 334 | 335 | │ │ 336 | 337 | ``` 338 | 339 | #### 1. Handling a Command 340 | 341 | A command comes into the application and is routed to a command handler. The command handler initialises an aggregate and loads up its state from events in the store. The command handler then defers to the aggregate to handle the command. It then stores any new events raised by the aggregate into the event store. 342 | 343 | ```ruby 344 | class AddTodoCommandHandler 345 | def handle(id:, title:, description:) 346 | # The repository provides access to the event store for saving and loading aggregates 347 | repository = EventSourcery::Repository.new( 348 | event_source: EventSourcery.config.event_source, 349 | event_sink: EventSourcery.config.event_sink, 350 | ) 351 | 352 | # Load up the aggregate from events in the store 353 | aggregate = repository.load(TodoAggregate, id) 354 | 355 | # Defer to the aggregate to execute the add command. 356 | # This may raise new events in the aggregate which we'll need to save. 357 | aggregate.add(title, description) 358 | 359 | # Save any newly raised events back into the event store 360 | repository.save(aggregate) 361 | end 362 | end 363 | ``` 364 | 365 | #### 2. Updating a Projection 366 | 367 | You can think of projections as read-only models. They are created and updated by projectors and show different views over the events that are the source of truth for our application state. Projections are typically stored as database tables. 368 | 369 | Projecting is process of converting (or collecting) a stream of events into these database tables. You can think of this process as a fold over a sequence of events. 370 | 371 | A projector is a process that listens for new events in the event store. When it sees a new event it cares about it updates its projection. 372 | 373 | ```ruby 374 | class OutstandingTodosProjector 375 | include EventSourcery::Postgres::Projector 376 | 377 | projector_name :outstanding_todos 378 | 379 | # Database table that forms the projection. 380 | table :outstanding_todos do 381 | column :todo_id, 'UUID NOT NULL' 382 | column :title, :text 383 | column :description, :text 384 | end 385 | 386 | # Handle TodoAdded events by adding the todo to our projection. 387 | project TodoAdded do |event| 388 | table.insert( 389 | todo_id: event.aggregate_id, 390 | title: event.body['title'], 391 | description: event.body['description'], 392 | ) 393 | end 394 | 395 | # Handle TodoCompleted events by removing the todo from our projection. 396 | project TodoCompleted, TodoAbandoned do |event| 397 | table.where(todo_id: event.aggregate_id).delete 398 | end 399 | end 400 | ``` 401 | 402 | #### 3. Handling a Query 403 | 404 | A query comes into the application and is routed to a query handler. The query handler queries the projection directly and returns the result. 405 | 406 | ```ruby 407 | module OutstandingTodos 408 | # Query handler that queries the projection table. 409 | class QueryHandler 410 | def handle 411 | EventSourceryTodoApp.projections_database[:outstanding_todos].all 412 | end 413 | end 414 | end 415 | ``` 416 | --------------------------------------------------------------------------------