Dolganov Sergey @ Evil Martians

Copenhagen Ruby Brigade, June '19

Railways, States & Sagas: pure Ruby for Wizards

Title Text

What's my problem?

eBay for Business (eBaymag)

Distributed system built upon lots of API calls to external systems, e.g.

eBay, Russian Post, Google Translate

eBaymag Logistics

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

Chapter One

Railways

 

Parcel

created

Parcel

verified

Parcel

delivered

Parcel Life-Cycle

Parcel

shipped

Parcel

registered

Parcel

estimated

Parcel

created

Parcel

verified

Parcel

estimated

User

data

validation

Parcel

data

validation

Success

Output

Result

Parcel

created

Parcel

verified

Parcel

estimated

Output

Result

Failure

User

data

validation

Parcel

data

validation

Parcel

created

Parcel

verified

Parcel

estimated

Output

Result

Failure

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

Existing Solutions

Chapter Two

Sagas

 

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?

Sagas

(with Orchestration)

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

Print

...

Success

Success

Validate

Print

Saga Orchestrator

Parcel

Parcel*

Aaand that's it?!

DirtyPipeline

(Sagas)

DirtyPipeline

(Sagas)

Real World

What if?

  • Orchestrator runtime error
  • Another pipeline runs in parallel
  • Application shuts down in the middle

Solution from eBaymag

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

Pipeline

(Action 1 Attempt)

(Action N Attempt)

Success

Failure

(Done)

Subject

call

call

Events

(Action 1 Attempt)

(Action N Attempt)

(Done)

Cleaner

1

2

Success

...

Success

Success

Action 1

Action N

applied?

applied?

Events

...

Success

Success

Action 1

Action N

Pipeline

(Action 1 Attempt)

(Action N Attempt)

Success

Failure

(Done)

Subject

call

Events

Events

(Action 1 Attempt)

(Action N Attempt)

(Done)

Cleaner

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)

Cleaner

Failure

undo

Solution from eBaymag

  • Pipeline
  • Lock
  • Action
  • Events
  • Clean
  • Status
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

🚊

Pipeline

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

🔒

Lock

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

⚙️

Action

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

🧳

Events

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

🧹

Clean


  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

Status

🚥

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

We won the data consistency battle!

But...

How's about share?

Tram::Events

DirtyPipeline

BloodContracts

(on Refinement Types)

Thanks

Our Blog: evl.ms/chronicles

 

 

Pipelines Gist: bit.ly/DP_RUBY_BY19

Railways, States & Sagas: pure Ruby for Wizards

By Sergey Dolganov

Railways, States & Sagas: pure Ruby for Wizards

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!

  • 684