Class: AceMQ::AMQP::Patterns::Saga

Inherits:
Object
  • Object
show all
Defined in:
lib/acemq/amqp/patterns/saga.rb

Overview

A sequence of steps where each one knows how to undo itself.

booking = Patterns::Saga.named("place-order") do |saga|
saga.step("take-payment") { |order| payments.charge(order) }
    .compensate_with { |order| payments.refund(order) }
saga.step("reserve-stock") { |order| inventory.reserve(order) }
    .compensate_with { |order| inventory.release(order) }
saga.step("book-courier") { |order| couriers.book(order) }
end

result = booking.run(order)

If book-courier raises, the stock is released and the payment refunded, in that order, and AceMQ::AMQP::Patterns::SagaResult#compensated? says so.

What this is not

Not a distributed transaction. Nothing is isolated: after take-payment the customer's money really has moved, and anybody looking sees that it has. If book-courier then fails, the refund is a new fact rather than an erasure of the old one, and for a few seconds the world contained a charge that should not have happened. That is not a defect in this class; it is what compensating a real-world action means, and a saga is honest about it where a two-phase commit pretends otherwise.

So the steps must be things that can be undone by doing something else. Sending an email cannot be compensated — the apology is a second email, not an unsend — and a saga step that sends one should be the last step, after everything that can still fail.

Not durable. This runs in one process and its state is on the stack. A crash midway leaves the saga half-applied with nothing to resume it, which is the honest limitation of the in-process form and is why the compensations run on the way out of run rather than after a restart. Where a saga must survive the process, the steps have to be messages and the state has to be in a database — a much larger thing, and it is not this.

For most systems the in-process form is the right one: it turns "remember to undo the three things you already did" from a comment into something the code can see.

When compensation itself fails

It is tried, it is collected, and the remaining compensations still run. The alternative — stopping — leaves more undone than continuing does. What comes back is a SagaResult listing what could not be undone, and that list is the thing to alert on: it is the set of facts a human now has to reconcile by hand.

Nothing here touches a broker

No message is published, no header is set, and a saga runs perfectly well in a process that has never opened a connection. It is here rather than anywhere else because the work a saga sequences is usually the work a message asked for, and because the other AceMQ libraries put it here.

Defined Under Namespace

Classes: Builder, Step

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(name:, steps:) ⇒ Saga

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns a new instance of Saga.



172
173
174
175
176
# File 'lib/acemq/amqp/patterns/saga.rb', line 172

def initialize(name:, steps:)
  @name = name
  @steps = steps.freeze
  freeze
end

Instance Attribute Details

#name ⇒ Object (readonly)

What this saga is called, in results.



150
151
152
# File 'lib/acemq/amqp/patterns/saga.rb', line 150

def name
  @name
end

Class Method Details

.named(name) {|builder| ... } ⇒ Saga, Builder

Starts building one.

With a block, the builder is yielded and the saga is built and returned, which is the ordinary way to write one. Without a block the builder itself comes back and AceMQ::AMQP::Patterns::Saga::Builder#build finishes it, for a saga assembled across several methods.

Parameters:

  • name (String) —

    what this saga is called, in results

Yields:

  • (builder)

Returns:

Raises:

  • (ArgumentError)


161
162
163
164
165
166
167
168
169
# File 'lib/acemq/amqp/patterns/saga.rb', line 161

def self.named(name)
  raise ArgumentError, "a saga needs a name" if name.to_s.empty?

  builder = Builder.new(name.to_s)
  return builder unless block_given?

  yield builder
  builder.build
end

Instance Method Details

#run(subject) ⇒ SagaResult

Runs the steps, compensating in reverse if one fails.

Never raises for a step failure, because a caller needs the compensation report more than it needs a backtrace. Anything the steps raise that is not a StandardError — an Interrupt, a SignalException — is left alone and nothing is compensated: the process is going away, and a compensation running on the way out of a SIGTERM would be a compensation nobody can be sure finished.

Parameters:

  • subject (Object) —

    what to operate on

Returns:



189
190
191
192
193
194
195
196
197
198
199
200
201
202
# File 'lib/acemq/amqp/patterns/saga.rb', line 189

def run(subject)
  completed = []

  @steps.each do |step|
    step.action.call(subject)
    completed << step
  rescue StandardError => e
    unresolved = compensate(subject, completed)
    return SagaResult.compensated_run(@name, step.name, e,
                                      completed.map(&:name), unresolved)
  end

  SagaResult.completed_run(@name, completed.map(&:name))
end

#step_names ⇒ Object

The step names, in order.



205
# File 'lib/acemq/amqp/patterns/saga.rb', line 205

def step_names = @steps.map(&:name)

#to_s ⇒ Object



207
# File 'lib/acemq/amqp/patterns/saga.rb', line 207

def to_s = "Saga{#{@name}: #{step_names.join(" -> ")}}"