Upload
robert-pankowecki
View
931
Download
0
Embed Size (px)
Citation preview
2 years after the first event - The Saga Pattern
Message Driven Architecture● Commands
● Events
Commandsparams,
form objects,
real command objects
module Seating
class BookEntranceCommand
include Command
attribute :booking_id, String
attribute :event_id, Integer
attribute :seats, Array[Seat]
attribute :places, Array[GeneralAdmission]
validates_presence_of :event_id, :booking_id
validate do
unless (seats.present? || places.present?)
errors.add(:base, "Missing seats or places")
end
end
end
end
Eventsmodule Seating
module Events
SeatingSetUp = Class.new(EventStore::Event)
SeatingDisabled = Class.new(EventStore::Event)
EntranceBooked = Class.new(EventStore::Event)
EntranceUnBooked = Class.new(EventStore::Event)
end
end
Send PDF via Postal
1. PostalAddedToOrder2. PostalAddressFilledOut 3. PdfGenerated4. PaymentPaid
5. => SendPdfViaPostal
How did we get there?
Publish events
class RegisterUserService def call(email, password) user = User.register(email, password) event_store.publish(Users::UserRegisteredByEmail.new( id: user.id, email: user.email, )) endend
Publish events
event_store.publish(SeasonPassConnectedToAConference.new({ pass: { internal_id: pass.id, external_id: pass.external_id, }, season_id: season.id, booking_id: booking_id(event, pass),
id: ticket.id, event_id: event.id, ticket_id: ticket.id, ticket_type_id: ticket.ticket_type_id, barcode: ticket.barcode,
seat: { uuid: seat.uuid, label: seat.label, category: seat.category, },
holder: { user_id: holder.user_id, email: holder.email, name: holder.name, },}))
Introducehandlers
module Search class UserRegisteredHandler singleton_class.prepend(YamlDeserializeFact)
def self.perform(event) new.call(event) rescue => e Honeybadger.notify(e) end
def call(event) Elasticsearch::Model.client.index( index: 'users', type: 'admin_search_user', id: event.data.fetch(:id), body: { email: event.data.fetch(:email) }) end endend
Introducehandlers
class WelcomeEmailForImportedUsers singleton_class.prepend(YamlDeserializeFact) @queue = :other
def call(fact) data = fact.data organizer = User.find(data.fetch(:organizer_id)) organization = Organization.find(data.fetch(:organization_id)) season = Season.find(data.fetch(:season_id))
config = Seasons::Config.find_by_organizer_id(organizer.id) || Seasons::Config.new( class_name: "Seasons::Mailer", method_name: "welcome_imported_season_pass_holder", )
I18n.with_locale(organization.default_locale) do config.class_name.constantize.send(config.method_name, organization: organization, email: data.fetch(:email), reset_password_token: data.fetch(:password_token), season_name: season.name, organizer_name: organizer.name, ).deliver end endend
One class handles multipleevents
TicketRefunded: stream: "Order$%{order_id}" handlers: - Scanner::TicketRevokedHandler - Salesforce::EventDataChangedHandler - Reporting::Financials::TicketRefundedHandler - Seating::ReleasePlaces
TicketCancelledByAdmin: stream: "Order$%{order_id}" handlers: - Scanner::TicketRevokedHandler
module Scanner class TicketRevokedHandler singleton_class.prepend(YamlDeserializeFact)
def call(event) data = event.data revoke = Scanner::Revoke.new revoke.call( barcode: data.fetch(:barcode), event_id: data.fetch(:event_id), ) end endend
Handler+State=Saga
class CheckBankSettingsSaga singleton_class.prepend(::YamlDeserializeFact)
class State < ActiveRecord::Base self.table_name = 'check_bank_settings_saga' end
def call(event) data = event.data case event when ConferenceCreated, ConferenceCloned store_event(data) when TicketKindCreated check_bank_settings(data) end end
Handler+State=Saga
def store_event(data) State.create!( event_id: data[:event_id], organization_id: data[:organization_id], organizer_id: data[:user_id], checked: false ) end
def check_bank_settings(data) State.transaction do record = State.lock.find_by_event_id(data[:event_id]) return if record.checked? record.update_attribute(:checked, true)
Organizer::CheckBankSettings.new.call( record.organizer_id, record.organization_id, record.event_id ) end endend
Paymentpaid too late
1. OrderPlaced2. OrderExpired3. PaymentPaid
4. => ReleasePayment
How to implement a Saga?Technical details
Sync handlersfrom EventStore
module Search class UserRegisteredHandler
def self.perform(event) new.call(event) rescue => e Honeybadger.notify(e) end
def call(event) Elasticsearch::Model.client.index( index: 'users', type: 'admin_search_user', id: event.data.fetch(:id), body: { email: event.data.fetch(:email) }) end endend
Contain exceptionsin sync handlers
module Search class UserRegisteredHandler
def self.perform(event) new.call(event) rescue => e Honeybadger.notify(e) end
endend
class RegisterUserService def call(email, password) ActiveRecord::Base.transaction do user = User.register(email, password) event_store.publish(UserRegisteredByEmail.new( id: user.id, email: user.email, )) end endend
Async handlers
def call(handler_class, event)
if handler_class.instance_variable_defined?(:@queue)
Resque.enqueue(handler_class, YAML.dump(event))
else
handler_class.perform(YAML.dump(event))
end
end
Async handlers(after commit)
def call(handler_class, event) if handler_class.instance_variable_defined?(:@queue) if ActiveRecord::Base.connection.transaction_open? ActiveRecord::Base. connection. current_transaction. add_record( FakeActiveRecord.new( handler, YAML.dump(event)) ) else Resque.enqueue(handler_class, YAML.dump(event)) end else handler_class.perform(YAML.dump(event)) end end
https://blog.arkency.com/2015/10/run-it-in-background-job-after-commit/
YAML
module Search class UserRegisteredHandler singleton_class.prepend(::YamlDeserializeEvent)
def self.perform(event) new.call(event) rescue => e Honeybadger.notify(e) end endend
module YamlDeserializeFact def perform(arg) case arg when String super(YAML.load(arg)) else super end endend
Re-raise exceptionsin async handlers
module Search
class UserRegisteredHandler
@queue = :low_priority
def self.perform(event)
new.call(event)
rescue => e
Honeybadger.notify(e)
raise
end
end
end
Initializing the state of a saga
class PostalSaga singleton_class.prepend(YamlDeserializeFact) @queue = :low_priority
# add_index "sagas", ["order_id"], unique: true class State < ActiveRecord::Base self.table_name = 'sagas'
def self.get_by_order_id(order_id) do transaction do yield lock.find_or_create_by(order_id: order_id) end rescue ActiveRecord::RecordNotUnique retry end end
def call(fact) data = fact.data State.get_by_order_id(data.fetch(:order_id)) do |state| state.do_something state.save! end endend
Processing an event by a saga
class Postal::FilledOut singleton_class.prepend(YamlDeserializeFact) @queue = :low_priority
def self.perform(event) new().call(event) rescue => e Honeybadger.notify(e, { context: { event: event } } ) raise end
def call(event) data = event.data order_id = data.fetch(:order_id) State.get_by_order_id(order_id) do |state| state.filled_out( filled_out_at: Time.zone.now, adapter: Rails.configuration.insurance_adapter, ) end endend
Triggering a command
class Postal::State < ActiveRecord::Base def added_to_basket(added_to_basket_at:, uploader:) self.added_to_basket_at ||= added_to_basket_at save! maybe_send_postal_via_api(uploader: uploader) end
def filled_out(filled_out_at:, uploader:) self.filled_out_at ||= filled_out_at save! maybe_send_postal_via_api(uploader: uploader) end
def paid(paid_at:, uploader:) self.paid_at ||= paid_at save! maybe_send_postal_via_api(uploader: uploader) end
def tickets_pdf_generated(generated_at:, pdf_id:, uploader:) return if self.tickets_generated_at self.tickets_generated_at ||= generated_at self.pdf_id ||= pdf_id save! maybe_send_postal_via_api(uploader: uploader) end
Triggering a command
class Postal::State < ActiveRecord::Base private
def maybe_send_postal_via_api(uploader:) return unless added_to_basket_at && paid_at && filled_out_at && tickets_generated_at return if uploaded_at
uploader.transmit(Pdf.find(pdf_id))
self.uploaded_at = Time.now save! rescue # error handling... endend
Triggering a command (better way)
class Postal::State < ActiveRecord::Base
private
def maybe_send_postal_via_api return unless added_to_basket_at && paid_at && filled_out_at && tickets_generated_at return if uploaded_at
self.uploaded_at = Time.now save!
command_bus.send(DeliverPostalPdf.new({ order_id: order_id, pdf_id: pdf_id })) endend
https://github.com/pawelpacana/command_bus
Thank you!Make events, not CRUD
35% DISCOUNTwith code
WROCLOVE2016
https://blog.arkency.com/products/
Resources
● original saga patterns○ http://vasters.
com/clemensv/2012/09/01/Sagas.aspx○ http://kellabyte.com/2012/05/30/clarifying-the-
saga-pattern/ ● saga - process manager
○ https://msdn.microsoft.com/en-us/library/jj591569.aspx
○ http://udidahan.com/2009/04/20/saga-persistence-and-event-driven-architectures/
● other○ http://verraes.net/2014/05/functional-
foundation-for-cqrs-event-sourcing/○ http://stackoverflow.
com/questions/15528015/what-is-the-difference-between-a-saga-a-process-manager-and-a-document-based-ap
○ http://theawkwardyeti.com/comic/moment/