Sergey Dolganov
Evil Martians developer, open-source enthusiast, traveller and drummer.
Dolganov Sergey @ Evil Martians
Copenhagen Ruby Brigade, June '19
Order
Order
Order
def create
account = Account.find(params[:account_id])
parcel = account.parcels.find(params[:id])
provider = params.fetch(:provider) # => "UPS"
status =
Logistics::Pipeline.chain(:Validate)
.chain(:GetTransportationCost)
.chain(:Register)
.chain(:PayOnline)
.chain(:PrintLabel)
.new(parcel, provider: provider)
.call
render json: status.payload
end
Parcel
created
Parcel
verified
Parcel
delivered
Parcel
shipped
Parcel
registered
Parcel
estimated
Parcel
created
Parcel
verified
Parcel
estimated
User
data
validation
Parcel
data
validation
Output
Result
Parcel
created
Parcel
verified
Parcel
estimated
Output
Result
User
data
validation
Parcel
data
validation
Parcel
created
Parcel
verified
Parcel
estimated
Output
Result
User
data
validation
Parcel
data
validation
class Result
attr_accessor :success, :data
def initialize(success, data)
@success = success
@data = data
end
def success?; !!@success; end
def failure?; !success?; end
def self.success(data = nil)
new(true, data)
end
def self.failure(data = nil)
new(false, data)
end
end
class Chain
def initialize
@result = Result.success
@store = []
end
def call
while !@store.empty?
@store.shift.tap do |action|
@result = action.call unless @result.failure?
end
end
@result
end
def push(&block)
@store << block
end
end
module SimpleRailway
def initialize(*)
@chain = Chain.new
super
end
def run
@chain.call
end
def chain
@chain.push { yield(self) } if block_given?; self
end
def when_success
yield(run.data) if run.success?; self
end
def when_failure
yield(run.data) if run.failure?; self
end
protected
def Success(data)
Result.success(data)
end
def Failure(data)
Result.failure(data)
end
end
in single database
we're able to rollback sequence of changes with a transaction,
but...
we have separate databases, so
is there a way to have distributed transaction?
1. Wrap each mutation in a Command
(each could be done and undone, e.g. #up and #down in AR migration)
2. Chain Command
3. Execute chain
4. If one fails run #undo
...
Success
Success
Validate
PrintLabel
Validate#call
PrintLabel#call
...
Failure
Failure
Validate
PrintLabel
Validate#undo
PrintLabel#undo
...
Failure
Failure
Validate
...
Success
Success
Validate
Saga Orchestrator
Parcel
Parcel*
(Sagas)
(Sagas)
def create
account = Account.find(params[:account_id])
parcel = account.parcels.find(params[:id])
provider = params.fetch(:provider) # => "UPS"
status =
Logistics::Pipeline.chain(:Validate)
.chain(:GetTransportationCost)
.chain(:Register)
.chain(:PayOnline)
.chain(:PrintLabel)
.new(parcel, provider: provider)
.call
render json: status.payload
end
...
Success
Success
Action 1
Action N
(Action 1 Attempt)
(Action N Attempt)
Success
Failure
(Done)
Subject
call
call
Events
(Action 1 Attempt)
(Action N Attempt)
(Done)
1
2
Success
...
Success
Success
Action 1
Action N
applied?
applied?
Events
...
Success
Success
Action 1
Action N
(Action 1 Attempt)
(Action N Attempt)
Success
Failure
(Done)
Subject
call
Events
Events
(Action 1 Attempt)
(Action N Attempt)
(Done)
1
2
Failure
...
Success
Success
Action N
Action 1
undo
(reversed)
undo
...
Success
Success
Action N
Action 1
Failure
lock Subject
with error
Events
(Action 1 Attempt)
(Action N Attempt)
(Done)
1
2
(reversed)
Failure
undo
def create
account = Account.find(params[:account_id])
parcel = account.parcels.find(params[:id])
provider = params.fetch(:provider) # => "UPS"
status =
Logistics::Pipeline.chain(:Validate)
.chain(:GetTransportationCost)
.chain(:Register)
.chain(:PayOnline)
.chain(:PrintLabel)
.new(parcel, provider: provider)
.call
render json: status.payload
end
module Logistics
class Pipeline
class Register < DirtyPipeline::Action
alias :parcel :subject
policy contents_completeness: ReadyForRegistrationPolicy
def call
validate!(contents_completeness: parcel)
response = validate_response!(api_client.register(parcel: parcel))
Success(response.mapped)
end
def undo
validate_response!(api_client.cancel(parcel: parcel))
Success(CLEAR_REGISTRATION)
end
def applied?
api_client.find_shipment(parcel: parcel).mapped["external_id"].present?
end
end
end
end
class DirtyPipeline::Base
# ...
attr_reader :subject, :context, :status
def initialize(subject, context: nil, **opts)
@subject = subject
@status = Success.new
@context = (context || Hash.new).merge(opts)
end
def call(**context)
Lock.new(self.class.root, @subject).lock! do
shared_context = @context.merge(context.stringify_keys)
self.class.steps.each do |action|
@status = action.call(@subject, context: shared_context).status
break if @status.failure?
end
done = Events.publish!(Events.generate(self))
clean = Clean.new(done).tap(&:call)
@status.merge(clean.status)
end
end
end
class DirtyPipeline::Base
class << self
attr_writer :root
def root
@root || self
end
def inherited(new_klass)
new_klass.instance_variable_set(:@steps, [])
new_klass.root = self.root
end
def chain(klass)
c = Class.new(Base)
c.instance_variable_set(:@steps, self.steps.to_a + [klass])
c
end
end
# ...
end
class Pipelines::Lock
def initialize(pipeline)
@pipeline = pipeline
end
def self.fail!(subject, errors); end
def failed?; end
def locked?; end
def with_lock(&block); end
def lock!
return manual_failure if failed?
return locked_failure if locked?
with_lock(&block)
end
def manual_failure
Failure.new(errors: {self => :needs_manual_recovery})
end
def locked_failure
Failure.new(errors: {self => :pipeline_locked})
end
end
class DirtyPipelines::Action
class << self
# ...
def call(subject = nil, context: nil, **opts)
new(subject, context: context, **opts).tap(&:call)
end
end
attr_reader :subject, :status, :context
def initialize(subject, context: nil, **opts)
@context = (context || Hash.new).merge(opts)
@subject = subject
@subject ||= self.class.find_subject(**context.symbolize_keys)
@status = Success.new
end
def call; end
def undo; end
def applied?; true; end
end
class DirtyPipelines::Action
# ...
class << self
def from_event(event)
event = Events.unpack(event)
event_as_kwargs = event.symbolize_keys
event_as_kwargs.merge!(subject: find_subject(**event_as_kwargs)
build_action(**event_as_kwargs)
end
def build_action(action_name:, context:, subject:, **)
Kernel.const_get(action_name).new(subject, context: context.to_h)
end
def find_subject(subject_name:, subject_id:, **)
Kernel.const_get(subject_name).find(subject_id)
end
end
end
class DirtyPipelines::Action
module WrappedCall
def call
Events.publish! Events.generate(self)
super
end
end
def self.inherited(child_klass)
child_klass.prepend(WrappedCall)
end
# ...
end
class DirtyPipeline::Events
# unpack Event's payload to a hash
def unpack(event)
end
# mark Event as processed
def complete!(event)
end
# read Events for a pipeline with subject
def select_for(pipeline, subject)
end
# built an Event
def generate(action_or_pipeline)
end
# publish freshly built Event to the Events stream
def publish!(event)
end
end
class DirtyPipeline::Clean
def initialize(done_event)
@event = done_event
end
def call
status = @event.call_success ? validate : undo
return status if status.success?
Lock.fail!(@subject, status.errors)
status
end
private
# ...
def pipeline_events
Events.select_for(@pipeline, @subject)
end
end
class DirtyPipeline::Clean
# ...
def undo
pipeline_events.reverse_each do |event|
action = Action.from_event(event).tap(&:undo)
return undo_failure(action) if action.status.failure?
Events.complete!(event)
end
Success.new
end
def undo_failure(action)
Failure.new(errors: {self => :undo_failed}, subject: action.subject)
.merge(action.status)
end
end
class DirtyPipeline::Clean
# ...
def validate
pipeline_events.each do |event|
action = Action.from_event(event)
return validation_failure(action) unless action.applied?
Events.complete!(event)
end
Success.new
end
def validation_failure(action)
Failure.new(errors: {self => :broken_state}, subject: action.subject)
end
end
class Status::Base
attr_reader :payload
alias :data :payload
def initialize(payload = nil)
@payload = payload || Hash.new
end
def merge(other_status)
raise ArgumentError unless Base === other_status
klass = Failure if other_status.failure? || failure?
klass ||= Success
klass.new(payload.merge(other_status.payload))
end
def failure?
!success?
end
def success?
errors.empty?
end
end
class Failure < Status::Base
def errors
payload[:errors].to_h
end
end
class Success < Status::Base
def errors
{}
end
end
(on Refinement Types)
By Sergey Dolganov
Have you ever heard about data consistency problems within the multi-service architecture? That problem could be solved using Sagas design pattern. In fact, it’s quite hard to implement. Want to know how? Welcome, on board, young magicians!
Evil Martians developer, open-source enthusiast, traveller and drummer.