The demo below simulates a three-step booking saga: reserve a flight, book a hotel, then rent a car. Each step can succeed or fail. Use the toggles to inject a failure at any step, then run the saga.
<!-- Saga demo — three steps that can each be toggled to fail -->
<div class="hint">A saga runs each step in its own service. If a step fails, all earlier steps fire compensating transactions to undo their work. Toggle the checkboxes to inject failures, then click Run Saga.</div>
<div class="controls">
<div class="toggle-row">
<label class="toggle-label">
<input type="checkbox" id="fail1"> Fail flight
</label>
<label class="toggle-label">
<input type="checkbox" id="fail2"> Fail hotel
</label>
<label class="toggle-label">
<input type="checkbox" id="fail3" checked> Fail car
</label>
</div>
</div>
<div class="steps" id="steps">
<div class="step" id="step1">
<div class="step-icon">✈</div>
<div class="step-label">Reserve flight</div>
<div class="step-status" id="s1status"></div>
</div>
<div class="arrow" id="arrow1">→</div>
<div class="step" id="step2">
<div class="step-icon">🏨</div>
<div class="step-label">Book hotel</div>
<div class="step-status" id="s2status"></div>
</div>
<div class="arrow" id="arrow2">→</div>
<div class="step" id="step3">
<div class="step-icon">🚗</div>
<div class="step-label">Rent car</div>
<div class="step-status" id="s3status"></div>
</div>
</div>
<div class="log-area" id="log"></div>
<div class="btns">
<button id="run" type="button">Run Saga</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
/* Visual states for each saga step */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 14px; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.controls { margin-bottom: .8rem; }
.toggle-row { display: flex; flex-wrap: wrap; gap: .6rem; }
.toggle-label { display: flex; align-items: center; gap: .35rem; font-size: .85rem;
cursor: pointer; user-select: none; }
.steps { display: flex; align-items: center; justify-content: center;
gap: .3rem; margin: .6rem 0; flex-wrap: wrap; }
.step { display: flex; flex-direction: column; align-items: center; gap: .2rem;
width: 82px; padding: .5rem .3rem; border-radius: 10px; border: 2px solid #cdd9e3;
background: #edf2f6; transition: background .25s, border-color .25s; }
.step.ok { background: #d4edda; border-color: #28a745; }
.step.fail { background: #f8d7da; border-color: #dc3545; }
.step.comp { background: #fff3cd; border-color: #ffc107; }
.step.pending { background: #e8eef3; border-color: #8ba4b8; }
.step-icon { font-size: 1.5rem; line-height: 1; }
.step-label { font-size: .72rem; font-weight: 600; text-align: center; }
.step-status { font-size: .68rem; color: #555; min-height: 1em; text-align: center; }
.arrow { font-size: 1.2rem; color: #8ba4b8; }
.log-area { background: #f4f6f8; border: 1px solid #cdd9e3; border-radius: 8px;
padding: .5rem .7rem; margin: .6rem 0; min-height: 3.5rem;
max-height: 130px; overflow-y: auto; font-size: .8rem; line-height: 1.55; }
.log-area .entry { padding: .05rem 0; }
.log-area .entry.ok { color: #155724; }
.log-area .entry.fail { color: #721c24; }
.log-area .entry.comp { color: #856404; }
.log-area .entry.info { color: #4a4a4a; }
.log-area .entry.done { color: #0a7d33; font-weight: 700; }
.log-area .entry.abort { color: #c92f3c; font-weight: 700; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .4rem; }
button { font: 600 14px system-ui; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .5; cursor: not-allowed; }
// Saga runner — each step commits locally; failures trigger reverse compensation
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Step definitions with labels and messages
const STEPS = [
{ id: 'step1', statusId: 's1status', failId: 'fail1', label: 'Reserve flight',
doMsg: '→ Step 1: Reserving flight…', okMsg: '✓ Flight reserved (committed in flight service)', failMsg: '✗ Flight reservation FAILED',
compMsg: '↩ Compensating: cancelling flight reservation' },
{ id: 'step2', statusId: 's2status', failId: 'fail2', label: 'Book hotel',
doMsg: '→ Step 2: Booking hotel…', okMsg: '✓ Hotel booked (committed in hotel service)', failMsg: '✗ Hotel booking FAILED',
compMsg: '↩ Compensating: cancelling hotel booking' },
{ id: 'step3', statusId: 's3status', failId: 'fail3', label: 'Rent car',
doMsg: '→ Step 3: Renting car…', okMsg: '✓ Car rented (committed in car service)', failMsg: '✗ Car rental FAILED',
compMsg: '↩ Compensating: releasing car reservation' }
];
// Append an entry to the event log
function logEntry(text, cls) {
const log = document.getElementById('log');
const e = document.createElement('div');
e.className = 'entry ' + cls;
e.textContent = text;
log.appendChild(e);
log.scrollTop = log.scrollHeight;
}
// Update a step card's visual state
function setStepState(step, state, statusText) {
const el = document.getElementById(step.id);
el.className = 'step ' + state;
document.getElementById(step.statusId).textContent = statusText;
}
function resetUI() {
document.getElementById('log').innerHTML = '';
STEPS.forEach(s => setStepState(s, '', ''));
document.getElementById('run').disabled = false;
}
// Main saga runner — async so we can animate step by step
// Each step commits independently in its own service (no global lock)
async function runSaga() {
document.getElementById('run').disabled = true;
document.getElementById('log').innerHTML = '';
STEPS.forEach(s => setStepState(s, 'pending', 'waiting…'));
const committed = []; // track which steps committed so we can compensate them
let failedAt = -1;
for (let i = 0; i < STEPS.length; i++) {
const step = STEPS[i];
const shouldFail = document.getElementById(step.failId).checked;
logEntry(step.doMsg, 'info');
setStepState(step, 'pending', 'running…');
await delay(600);
if (shouldFail) {
// This step failed — stop executing forward
setStepState(step, 'fail', 'FAILED');
logEntry(step.failMsg, 'fail');
failedAt = i;
break;
} else {
setStepState(step, 'ok', 'committed');
logEntry(step.okMsg, 'ok');
committed.push(step);
}
}
if (failedAt === -1) {
// All steps succeeded — saga committed
logEntry('✓ Saga committed — all three reservations hold.', 'done');
} else {
// Some step failed — run compensating transactions in reverse order
// Compensate in reverse order (last committed first)
logEntry('⚠ Failure detected — running compensating transactions (reverse order)…', 'info');
for (let i = committed.length - 1; i >= 0; i--) {
const step = committed[i];
await delay(500);
setStepState(step, 'comp', 'compensated');
logEntry(step.compMsg, 'comp');
}
logEntry('✗ Saga aborted — all earlier steps have been compensated. State is clean.', 'abort');
}
}
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
document.getElementById('run').addEventListener('click', runSaga);
document.getElementById('reset').addEventListener('click', resetUI);
resetUI();
<!-- Saga demo — three steps that can each be toggled to fail -->
<div class="hint">A saga runs each step in its own service. If a step fails, all earlier steps fire compensating transactions to undo their work. Toggle the checkboxes to inject failures, then click Run Saga.</div>
<div class="controls">
<div class="toggle-row">
<label class="toggle-label">
<input type="checkbox" id="fail1"> Fail flight
</label>
<label class="toggle-label">
<input type="checkbox" id="fail2"> Fail hotel
</label>
<label class="toggle-label">
<input type="checkbox" id="fail3" checked> Fail car
</label>
</div>
</div>
<div class="steps" id="steps">
<div class="step" id="step1">
<div class="step-icon">✈</div>
<div class="step-label">Reserve flight</div>
<div class="step-status" id="s1status"></div>
</div>
<div class="arrow" id="arrow1">→</div>
<div class="step" id="step2">
<div class="step-icon">🏨</div>
<div class="step-label">Book hotel</div>
<div class="step-status" id="s2status"></div>
</div>
<div class="arrow" id="arrow2">→</div>
<div class="step" id="step3">
<div class="step-icon">🚗</div>
<div class="step-label">Rent car</div>
<div class="step-status" id="s3status"></div>
</div>
</div>
<div class="log-area" id="log"></div>
<div class="btns">
<button id="run" type="button">Run Saga</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
/* Visual states for each saga step */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 14px; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.controls { margin-bottom: .8rem; }
.toggle-row { display: flex; flex-wrap: wrap; gap: .6rem; }
.toggle-label { display: flex; align-items: center; gap: .35rem; font-size: .85rem;
cursor: pointer; user-select: none; }
.steps { display: flex; align-items: center; justify-content: center;
gap: .3rem; margin: .6rem 0; flex-wrap: wrap; }
.step { display: flex; flex-direction: column; align-items: center; gap: .2rem;
width: 82px; padding: .5rem .3rem; border-radius: 10px; border: 2px solid #cdd9e3;
background: #edf2f6; transition: background .25s, border-color .25s; }
.step.ok { background: #d4edda; border-color: #28a745; }
.step.fail { background: #f8d7da; border-color: #dc3545; }
.step.comp { background: #fff3cd; border-color: #ffc107; }
.step.pending { background: #e8eef3; border-color: #8ba4b8; }
.step-icon { font-size: 1.5rem; line-height: 1; }
.step-label { font-size: .72rem; font-weight: 600; text-align: center; }
.step-status { font-size: .68rem; color: #555; min-height: 1em; text-align: center; }
.arrow { font-size: 1.2rem; color: #8ba4b8; }
.log-area { background: #f4f6f8; border: 1px solid #cdd9e3; border-radius: 8px;
padding: .5rem .7rem; margin: .6rem 0; min-height: 3.5rem;
max-height: 130px; overflow-y: auto; font-size: .8rem; line-height: 1.55; }
.log-area .entry { padding: .05rem 0; }
.log-area .entry.ok { color: #155724; }
.log-area .entry.fail { color: #721c24; }
.log-area .entry.comp { color: #856404; }
.log-area .entry.info { color: #4a4a4a; }
.log-area .entry.done { color: #0a7d33; font-weight: 700; }
.log-area .entry.abort { color: #c92f3c; font-weight: 700; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .4rem; }
button { font: 600 14px system-ui; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .5; cursor: not-allowed; }
// Saga runner — each step commits locally; failures trigger reverse compensation
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Step definitions with labels and messages
const STEPS = [
{ id: 'step1', statusId: 's1status', failId: 'fail1', label: 'Reserve flight',
doMsg: '→ Step 1: Reserving flight…', okMsg: '✓ Flight reserved (committed in flight service)', failMsg: '✗ Flight reservation FAILED',
compMsg: '↩ Compensating: cancelling flight reservation' },
{ id: 'step2', statusId: 's2status', failId: 'fail2', label: 'Book hotel',
doMsg: '→ Step 2: Booking hotel…', okMsg: '✓ Hotel booked (committed in hotel service)', failMsg: '✗ Hotel booking FAILED',
compMsg: '↩ Compensating: cancelling hotel booking' },
{ id: 'step3', statusId: 's3status', failId: 'fail3', label: 'Rent car',
doMsg: '→ Step 3: Renting car…', okMsg: '✓ Car rented (committed in car service)', failMsg: '✗ Car rental FAILED',
compMsg: '↩ Compensating: releasing car reservation' }
];
// Append an entry to the event log
function logEntry(text, cls) {
const log = document.getElementById('log');
const e = document.createElement('div');
e.className = 'entry ' + cls;
e.textContent = text;
log.appendChild(e);
log.scrollTop = log.scrollHeight;
}
// Update a step card's visual state
function setStepState(step, state, statusText) {
const el = document.getElementById(step.id);
el.className = 'step ' + state;
document.getElementById(step.statusId).textContent = statusText;
}
function resetUI() {
document.getElementById('log').innerHTML = '';
STEPS.forEach(s => setStepState(s, '', ''));
document.getElementById('run').disabled = false;
}
// Main saga runner — async so we can animate step by step
// Each step commits independently in its own service (no global lock)
async function runSaga() {
document.getElementById('run').disabled = true;
document.getElementById('log').innerHTML = '';
STEPS.forEach(s => setStepState(s, 'pending', 'waiting…'));
const committed = []; // track which steps committed so we can compensate them
let failedAt = -1;
for (let i = 0; i < STEPS.length; i++) {
const step = STEPS[i];
const shouldFail = document.getElementById(step.failId).checked;
logEntry(step.doMsg, 'info');
setStepState(step, 'pending', 'running…');
await delay(600);
if (shouldFail) {
// This step failed — stop executing forward
setStepState(step, 'fail', 'FAILED');
logEntry(step.failMsg, 'fail');
failedAt = i;
break;
} else {
setStepState(step, 'ok', 'committed');
logEntry(step.okMsg, 'ok');
committed.push(step);
}
}
if (failedAt === -1) {
// All steps succeeded — saga committed
logEntry('✓ Saga committed — all three reservations hold.', 'done');
} else {
// Some step failed — run compensating transactions in reverse order
// Compensate in reverse order (last committed first)
logEntry('⚠ Failure detected — running compensating transactions (reverse order)…', 'info');
for (let i = committed.length - 1; i >= 0; i--) {
const step = committed[i];
await delay(500);
setStepState(step, 'comp', 'compensated');
logEntry(step.compMsg, 'comp');
}
logEntry('✗ Saga aborted — all earlier steps have been compensated. State is clean.', 'abort');
}
}
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
document.getElementById('run').addEventListener('click', runSaga);
document.getElementById('reset').addEventListener('click', resetUI);
resetUI();
When all steps succeed the saga commits — all three reservations hold. When a step fails, every earlier step fires its compensating transaction to undo the reservation. The final state is always clean, but it is achieved through reversal, not rollback. Notice that during execution each step is briefly committed in its own service — another service could see a partially-booked state if it queries between steps. That is the price of eventual consistency.
Comments
Loading comments...