Class: AceMQ::AMQP::Patterns::Saga
- Inherits:
-
Object
- Object
- AceMQ::AMQP::Patterns::Saga
- 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
Instance Attribute Summary collapse
-
#name ⇒ Object
readonly
What this saga is called, in results.
Class Method Summary collapse
-
.named(name) {|builder| ... } ⇒ Saga, Builder
Starts building one.
Instance Method Summary collapse
-
#initialize(name:, steps:) ⇒ Saga
constructor
private
A new instance of Saga.
-
#run(subject) ⇒ SagaResult
Runs the steps, compensating in reverse if one fails.
-
#step_names ⇒ Object
The step names, in order.
- #to_s ⇒ Object
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.
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.
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(" -> ")}}" |