This article provides an overview of what I learned about the Saga pattern.
A Saga is a sequence of local transactions. Each local transaction updates the database and publishes a message or an event (either success or failure message) to the next transaction in the Saga.
It ensures atomicity. This means the transaction either completes fully or not at all. If one of the service fails, it undoes all previous changes (e.g., an update made to the database) made by other service. This process is known as a compensating transaction.
In saga we have two primary coordination style
- Orchestrator
- Choreography
The Orchestrator
is like a central brain. It directs each step of the Saga, telling services when to perform their operations and what to do next.
// Orchestrator
async function bookTripSaga() {
console.log("🧠 Starting trip booking saga...");
try {
await reserveFlight();
try {
await bookHotel();
try {
await rentCar();
console.log("✅ Trip booked successfully!");
} catch (err) {
console.error("❌ Car rental failed:", err.message);
await cancelHotel();
await cancelFlight();
}
} catch (err) {
console.error("❌ Hotel booking failed:", err.message);
await cancelFlight();
}
} catch (err) {
console.error("❌ Flight reservation failed:", err.message);
}
console.log("🧠 Saga finished.");
}
This example demonstrates the basic concept of a saga orchestrator with compensating transactions for failures, and less than ideal for production use.
In the Choreography
coordination style, services publish events that trigger a local transaction in other services.
Instead of a central orchestrator telling a service what to do, each service listens for events and reacts accordingly.
This Javascript example gives you a taste of the Choreography coordination style. 👇
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
// ======================
// Simulated Services
// ======================
eventBus.on('TripRequested', async () => {
console.log("✈️ [FlightService] Reserving flight...");
try {
await Promise.resolve("FlightReserved");
console.log("✅ [FlightService] Flight reserved");
eventBus.emit('FlightReserved');
} catch (err) {
console.error("❌ [FlightService] Flight reservation failed:", err.message);
}
});
eventBus.on('FlightReserved', async () => {
console.log("🏨 [HotelService] Booking hotel...");
try {
await Promise.resolve("HotelBooked");
console.log("✅ [HotelService] Hotel booked");
eventBus.emit('HotelBooked');
} catch (err) {
console.error("❌ [HotelService] Hotel booking failed:", err.message);
eventBus.emit('HotelBookingFailed');
}
});
eventBus.on('HotelBooked', async () => {
console.log("🚗 [CarService] Renting car...");
try {
await Promise.reject(new Error("Vehicle not available")); // simulate failure
console.log("✅ [CarService] Car rented");
eventBus.emit('TripCompleted');
} catch (err) {
console.error("❌ [CarService] Car rental failed:", err.message);
eventBus.emit('CarRentalFailed');
}
});
// Compensation handlers
eventBus.on('CarRentalFailed', async () => {
console.log("❌ [HotelService] Canceling hotel...");
await Promise.resolve("HotelCanceled");
console.log("❌ [FlightService] Canceling flight...");
await Promise.resolve("FlightCanceled");
console.log("🧠 Trip saga compensated.");
});
eventBus.on('HotelBookingFailed', async () => {
console.log("❌ [FlightService] Canceling flight...");
await Promise.resolve("FlightCanceled");
console.log("🧠 Trip saga compensated.");
});
eventBus.on('TripCompleted', () => {
console.log("🎉 Trip booking completed successfully!");
});
// ======================
// Start Saga
// ======================
function bookTripChoreographySaga() {
console.log("🧠 Starting trip booking (choreography saga)...");
eventBus.emit('TripRequested');
}
bookTripChoreographySaga();
The Isolation Challenge in Sagas
In a Saga, especially a distributed saga where each service manages its own database. If multiple sagas run concurrently, they might:
- Interfere with each other
- See partially updated data
- Trigger inconsistent business data
Sagas lacks built-in isolation. Imagine two users are trying to
- Book the last room in a hotel
- Both check availability → see it's free
- Both try to reserve it → race condition
Without isolation, both might believe they succeeded, leading to overbooking.
So in this case developers must implement it isolation explicitly
Implementing Countermeasures 🪖🪖.
- Pessimistic Concurrency (Reservation Pattern) : Temporarily “locks” a resource by marking it as reserved. So other sagas see it as unavailable.
Example
async function bookTripSaga(userId, hotelId, flightDetails ) {
try {
// Step 1: Reserve the hotel
const hotelReserved = await reserveHotel(hotelId, userId);
// If a saga is currenntly running, hotel is marked as reserved
// So other sagas see it as unavailable
if (!hotelReserved) throw new Error("Hotel unavailable");
// Step 2: Book the flight
const flightBooked = await bookFlight(flightDetails, userId);
if (!flightBooked) throw new Error("Flight booking failed");
// Step 3: Mark hotel as booked
await confirmHotel(hotelId, userId);
return { success: true, message: "Trip booked successfully" };
} catch (error) {
// Compensation to marked hotel as available
await releaseHotel(hotelId, userId);
return { success: false, message: error.message };
}
}
Other issues and considerations
-
Increased Latency :
- If the transaction makes synchronous calls (e.g., using the Orchestrator approach), each service call waits for a response before continuing.
- Compensatory transactions can also add latency to the overall response time. For example, if step 5 of a 6-step Saga fails, all previous steps must be rolled back, which adds additional latency.
Idempotency: Failures during a Saga (or during compensation) can be tricky to recover from. They require retry logic, and this is where idempotency becomes crucial.
👉 Read more on idempotency: Engineering Idempotency Keys
-
Dual writes issue: The dual-write issue occurs when two external systems has to atomically update the database and publish an event. The failure of either operation might lead to an inconsistent state. One way to solve this is to use the
transactional outbox pattern
.
Glossary
- Transactions:
In the context of databases and data storage systems, a transaction is any operation that is treated as a single unit of work, which either completes fully or does not complete at all.
Top comments (0)