<p class="hint">Each transaction receives a <b>snapshot</b> at the moment it starts.
It always reads the version of the row that was committed <em>before</em> its snapshot.
Writers create new versions without touching what readers see.</p>
<div id="timeline"></div>
<div class="controls">
<button id="btnA" type="button">Start Txn A</button>
<button id="btnW" type="button" disabled>Commit Write</button>
<button id="btnB" type="button" disabled>Start Txn B</button>
<button id="readA" type="button" disabled>Read (Txn A)</button>
<button id="readB" type="button" disabled>Read (Txn B)</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
<div id="log" class="log"></div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .8rem; line-height: 1.5; }
#timeline {
display: grid;
grid-template-columns: 80px 1fr 1fr 1fr;
border: 1px solid #cdd9e3;
border-radius: 8px;
overflow: hidden;
margin-bottom: .8rem;
}
.th { background: #1d3557; color: #fff; font-weight: 700;
padding: .4rem .6rem; font-size: .8rem; text-align: center; }
.cell {
padding: .4rem .6rem; border-top: 1px solid #e0e6eb;
font-size: .82rem; text-align: center; background: #f7f9fb;
min-height: 2.2rem; display: flex; align-items: center; justify-content: center;
}
.cell.event { background: #e8eef3; font-weight: 600; }
.cell.write { background: #fff3cd; }
.cell.read-old { background: #d4edda; color: #0a7d33; font-weight: 700; }
.cell.read-new { background: #cce5ff; color: #004085; font-weight: 700; }
.label { font-weight: 700; color: #1d3557; }
.controls { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .7rem; }
button {
font: 600 13px system-ui, sans-serif;
padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer;
}
button:disabled { opacity: .4; cursor: not-allowed; }
button.ghost { background: #fff; color: #1d3557; }
.log { font-size: .82rem; color: #333; line-height: 1.7; }
.log .ok { color: #0a7d33; font-weight: 700; }
.log .info { color: #004085; font-weight: 700; }
.log .write-msg { color: #856404; font-weight: 700; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// MVCC snapshot isolation simulation.
// State machine:
// phase 0: initial -> "Start Txn A"
// phase 1: A open -> "Commit Write"
// phase 2: write done -> "Start Txn B"
// phase 3: B open -> "Read A" + "Read B"
// phase 4: reads done -> done
const INITIAL_BALANCE = 100;
const WRITTEN_BALANCE = 250;
// Rows in the version store: { xid, balance, alive }
// xid = transaction ID that created this version
let versions = [];
let txnIdCounter = 1;
let txnA = null; // { xid, snapshotMaxXid }
let txnW = null;
let txnB = null;
let phase = 0;
const rows = []; // timeline display rows: { tick, label, txnA, write, txnB }
function commitVersion(xid, balance) {
versions.push({ xid, balance });
}
function readSnapshot(txn) {
// MVCC rule: read the latest version whose xid < txn.snapshotMaxXid
// (all lower xids are committed before our snapshot)
let best = null;
for (const v of versions) {
if (v.xid < txn.snapshotMaxXid) {
best = v; // later versions in the array have higher xids; take last qualifying
}
}
return best ? best.balance : null;
}
function log(msg, cls) {
const d = document.getElementById('log');
const line = document.createElement('div');
if (cls) { const s = document.createElement('span'); s.className = cls; s.textContent = msg; line.appendChild(s); }
else line.textContent = msg;
d.appendChild(line);
}
function renderTimeline() {
const tl = document.getElementById('timeline');
tl.innerHTML = '';
// Header
['Time', 'Txn A', 'Write Txn', 'Txn B'].forEach(h => {
const d = document.createElement('div'); d.className = 'th'; d.textContent = h; tl.appendChild(d);
});
rows.forEach(r => {
const tick = document.createElement('div');
tick.className = 'cell label'; tick.textContent = 't' + r.tick; tl.appendChild(tick);
const a = document.createElement('div');
a.className = 'cell' + (r.txnA ? ' event' : '') + (r.txnARead != null ? (r.txnARead === INITIAL_BALANCE ? ' read-old' : ' read-new') : '');
a.textContent = r.txnARead != null ? fmt('READ → $%txnARead%', { txnARead: r.txnARead }) : (r.txnA || '');
tl.appendChild(a);
const w = document.createElement('div');
w.className = 'cell' + (r.write ? ' write' : '');
w.textContent = r.write || '';
tl.appendChild(w);
const b = document.createElement('div');
b.className = 'cell' + (r.txnB ? ' event' : '') + (r.txnBRead != null ? (r.txnBRead === INITIAL_BALANCE ? ' read-old' : ' read-new') : '');
b.textContent = r.txnBRead != null ? fmt('READ → $%txnBRead%', { txnBRead: r.txnBRead }) : (r.txnB || '');
tl.appendChild(b);
});
}
let tick = 1;
function step(label, txnALabel, writeLabel, txnBLabel) {
rows.push({ tick: tick++, txnA: txnALabel || '', write: writeLabel || '', txnB: txnBLabel || '', label });
renderTimeline();
}
function addReadRow(txnARead, txnBRead) {
const r = rows[rows.length - 1];
// Add a new row specifically for reads
rows.push({ tick: tick++, txnA: '', write: '', txnB: '', txnARead, txnBRead });
renderTimeline();
}
function init() {
versions = [];
txnIdCounter = 1;
txnA = txnB = txnW = null;
phase = 0;
rows.length = 0;
tick = 1;
document.getElementById('log').innerHTML = '';
renderTimeline();
document.getElementById('btnA').disabled = false;
document.getElementById('btnW').disabled = true;
document.getElementById('btnB').disabled = true;
document.getElementById('readA').disabled = true;
document.getElementById('readB').disabled = true;
// Seed initial committed version (xid=0 = "before all txns")
commitVersion(0, INITIAL_BALANCE);
rows.push({ tick: tick++, txnA: '', write: fmt('Initial: $%INITIAL_BALANCE% committed (xid=0)', { INITIAL_BALANCE: INITIAL_BALANCE }), txnB: '' });
renderTimeline();
log(fmt('Database seeded: balance = $%INITIAL_BALANCE% (version xid=0)', { INITIAL_BALANCE: INITIAL_BALANCE }));
}
document.getElementById('btnA').addEventListener('click', () => {
if (phase !== 0) return;
txnA = { xid: txnIdCounter++, snapshotMaxXid: txnIdCounter }; // snapshot sees xids < current counter
step('', fmt('START (xid=%xid%, snapshot<%snapshotMaxXid%)', { xid: txnA.xid, snapshotMaxXid: txnA.snapshotMaxXid }), '', '');
log(fmt('Txn A started (xid=%xid%). Snapshot: sees versions with xid < %snapshotMaxXid%.', { xid: txnA.xid, snapshotMaxXid: txnA.snapshotMaxXid }), 'info');
phase = 1;
document.getElementById('btnA').disabled = true;
document.getElementById('btnW').disabled = false;
});
document.getElementById('btnW').addEventListener('click', () => {
if (phase !== 1) return;
txnW = { xid: txnIdCounter++ };
commitVersion(txnW.xid, WRITTEN_BALANCE);
step('', '', fmt('WRITE $%WRITTEN_BALANCE% → COMMIT (xid=%xid%)', { WRITTEN_BALANCE: WRITTEN_BALANCE, xid: txnW.xid }), '');
log(fmt('Write transaction committed: balance = $%WRITTEN_BALANCE% (version xid=%xid%).', { WRITTEN_BALANCE: WRITTEN_BALANCE, xid: txnW.xid }), 'write-msg');
phase = 2;
document.getElementById('btnW').disabled = true;
document.getElementById('btnB').disabled = false;
});
document.getElementById('btnB').addEventListener('click', () => {
if (phase !== 2) return;
txnB = { xid: txnIdCounter++, snapshotMaxXid: txnIdCounter };
step('', '', '', fmt('START (xid=%xid%, snapshot<%snapshotMaxXid%)', { xid: txnB.xid, snapshotMaxXid: txnB.snapshotMaxXid }));
log(fmt('Txn B started (xid=%xid%). Snapshot: sees versions with xid < %snapshotMaxXid%.', { xid: txnB.xid, snapshotMaxXid: txnB.snapshotMaxXid }), 'info');
phase = 3;
document.getElementById('btnB').disabled = true;
document.getElementById('readA').disabled = false;
document.getElementById('readB').disabled = false;
});
let readADone = false, readBDone = false;
document.getElementById('readA').addEventListener('click', () => {
if (phase !== 3 || readADone) return;
const val = readSnapshot(txnA);
readADone = true;
rows.push({ tick: tick++, txnA: fmt('READ → $%val%', { val: val }), write: '', txnB: '', txnARead: val });
renderTimeline();
log(fmt('Txn A reads: $%val% (saw xid<%snapshotMaxXid% → old version). %v%', { val: val, snapshotMaxXid: txnA.snapshotMaxXid, v: val === INITIAL_BALANCE ? '✓ Correct — write was not in its snapshot.' : 'Unexpected!' }), 'ok');
document.getElementById('readA').disabled = true;
if (readBDone) phase = 4;
});
document.getElementById('readB').addEventListener('click', () => {
if (phase !== 3 || readBDone) return;
const val = readSnapshot(txnB);
readBDone = true;
rows.push({ tick: tick++, txnA: '', write: '', txnB: fmt('READ → $%val%', { val: val }), txnBRead: val });
renderTimeline();
log(fmt('Txn B reads: $%val% (saw xid<%snapshotMaxXid% → new version). %v%', { val: val, snapshotMaxXid: txnB.snapshotMaxXid, v: val === WRITTEN_BALANCE ? '✓ Correct — write was in its snapshot.' : 'Unexpected!' }), 'ok');
document.getElementById('readB').disabled = true;
if (readADone) phase = 4;
});
document.getElementById('reset').addEventListener('click', init);
init();
<p class="hint">Each transaction receives a <b>snapshot</b> at the moment it starts.
It always reads the version of the row that was committed <em>before</em> its snapshot.
Writers create new versions without touching what readers see.</p>
<div id="timeline"></div>
<div class="controls">
<button id="btnA" type="button">Start Txn A</button>
<button id="btnW" type="button" disabled>Commit Write</button>
<button id="btnB" type="button" disabled>Start Txn B</button>
<button id="readA" type="button" disabled>Read (Txn A)</button>
<button id="readB" type="button" disabled>Read (Txn B)</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
<div id="log" class="log"></div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .8rem; line-height: 1.5; }
#timeline {
display: grid;
grid-template-columns: 80px 1fr 1fr 1fr;
border: 1px solid #cdd9e3;
border-radius: 8px;
overflow: hidden;
margin-bottom: .8rem;
}
.th { background: #1d3557; color: #fff; font-weight: 700;
padding: .4rem .6rem; font-size: .8rem; text-align: center; }
.cell {
padding: .4rem .6rem; border-top: 1px solid #e0e6eb;
font-size: .82rem; text-align: center; background: #f7f9fb;
min-height: 2.2rem; display: flex; align-items: center; justify-content: center;
}
.cell.event { background: #e8eef3; font-weight: 600; }
.cell.write { background: #fff3cd; }
.cell.read-old { background: #d4edda; color: #0a7d33; font-weight: 700; }
.cell.read-new { background: #cce5ff; color: #004085; font-weight: 700; }
.label { font-weight: 700; color: #1d3557; }
.controls { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .7rem; }
button {
font: 600 13px system-ui, sans-serif;
padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer;
}
button:disabled { opacity: .4; cursor: not-allowed; }
button.ghost { background: #fff; color: #1d3557; }
.log { font-size: .82rem; color: #333; line-height: 1.7; }
.log .ok { color: #0a7d33; font-weight: 700; }
.log .info { color: #004085; font-weight: 700; }
.log .write-msg { color: #856404; font-weight: 700; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// MVCC snapshot isolation simulation.
// State machine:
// phase 0: initial -> "Start Txn A"
// phase 1: A open -> "Commit Write"
// phase 2: write done -> "Start Txn B"
// phase 3: B open -> "Read A" + "Read B"
// phase 4: reads done -> done
const INITIAL_BALANCE = 100;
const WRITTEN_BALANCE = 250;
// Rows in the version store: { xid, balance, alive }
// xid = transaction ID that created this version
let versions = [];
let txnIdCounter = 1;
let txnA = null; // { xid, snapshotMaxXid }
let txnW = null;
let txnB = null;
let phase = 0;
const rows = []; // timeline display rows: { tick, label, txnA, write, txnB }
function commitVersion(xid, balance) {
versions.push({ xid, balance });
}
function readSnapshot(txn) {
// MVCC rule: read the latest version whose xid < txn.snapshotMaxXid
// (all lower xids are committed before our snapshot)
let best = null;
for (const v of versions) {
if (v.xid < txn.snapshotMaxXid) {
best = v; // later versions in the array have higher xids; take last qualifying
}
}
return best ? best.balance : null;
}
function log(msg, cls) {
const d = document.getElementById('log');
const line = document.createElement('div');
if (cls) { const s = document.createElement('span'); s.className = cls; s.textContent = msg; line.appendChild(s); }
else line.textContent = msg;
d.appendChild(line);
}
function renderTimeline() {
const tl = document.getElementById('timeline');
tl.innerHTML = '';
// Header
['Time', 'Txn A', 'Write Txn', 'Txn B'].forEach(h => {
const d = document.createElement('div'); d.className = 'th'; d.textContent = h; tl.appendChild(d);
});
rows.forEach(r => {
const tick = document.createElement('div');
tick.className = 'cell label'; tick.textContent = 't' + r.tick; tl.appendChild(tick);
const a = document.createElement('div');
a.className = 'cell' + (r.txnA ? ' event' : '') + (r.txnARead != null ? (r.txnARead === INITIAL_BALANCE ? ' read-old' : ' read-new') : '');
a.textContent = r.txnARead != null ? fmt('READ → $%txnARead%', { txnARead: r.txnARead }) : (r.txnA || '');
tl.appendChild(a);
const w = document.createElement('div');
w.className = 'cell' + (r.write ? ' write' : '');
w.textContent = r.write || '';
tl.appendChild(w);
const b = document.createElement('div');
b.className = 'cell' + (r.txnB ? ' event' : '') + (r.txnBRead != null ? (r.txnBRead === INITIAL_BALANCE ? ' read-old' : ' read-new') : '');
b.textContent = r.txnBRead != null ? fmt('READ → $%txnBRead%', { txnBRead: r.txnBRead }) : (r.txnB || '');
tl.appendChild(b);
});
}
let tick = 1;
function step(label, txnALabel, writeLabel, txnBLabel) {
rows.push({ tick: tick++, txnA: txnALabel || '', write: writeLabel || '', txnB: txnBLabel || '', label });
renderTimeline();
}
function addReadRow(txnARead, txnBRead) {
const r = rows[rows.length - 1];
// Add a new row specifically for reads
rows.push({ tick: tick++, txnA: '', write: '', txnB: '', txnARead, txnBRead });
renderTimeline();
}
function init() {
versions = [];
txnIdCounter = 1;
txnA = txnB = txnW = null;
phase = 0;
rows.length = 0;
tick = 1;
document.getElementById('log').innerHTML = '';
renderTimeline();
document.getElementById('btnA').disabled = false;
document.getElementById('btnW').disabled = true;
document.getElementById('btnB').disabled = true;
document.getElementById('readA').disabled = true;
document.getElementById('readB').disabled = true;
// Seed initial committed version (xid=0 = "before all txns")
commitVersion(0, INITIAL_BALANCE);
rows.push({ tick: tick++, txnA: '', write: fmt('Initial: $%INITIAL_BALANCE% committed (xid=0)', { INITIAL_BALANCE: INITIAL_BALANCE }), txnB: '' });
renderTimeline();
log(fmt('Database seeded: balance = $%INITIAL_BALANCE% (version xid=0)', { INITIAL_BALANCE: INITIAL_BALANCE }));
}
document.getElementById('btnA').addEventListener('click', () => {
if (phase !== 0) return;
txnA = { xid: txnIdCounter++, snapshotMaxXid: txnIdCounter }; // snapshot sees xids < current counter
step('', fmt('START (xid=%xid%, snapshot<%snapshotMaxXid%)', { xid: txnA.xid, snapshotMaxXid: txnA.snapshotMaxXid }), '', '');
log(fmt('Txn A started (xid=%xid%). Snapshot: sees versions with xid < %snapshotMaxXid%.', { xid: txnA.xid, snapshotMaxXid: txnA.snapshotMaxXid }), 'info');
phase = 1;
document.getElementById('btnA').disabled = true;
document.getElementById('btnW').disabled = false;
});
document.getElementById('btnW').addEventListener('click', () => {
if (phase !== 1) return;
txnW = { xid: txnIdCounter++ };
commitVersion(txnW.xid, WRITTEN_BALANCE);
step('', '', fmt('WRITE $%WRITTEN_BALANCE% → COMMIT (xid=%xid%)', { WRITTEN_BALANCE: WRITTEN_BALANCE, xid: txnW.xid }), '');
log(fmt('Write transaction committed: balance = $%WRITTEN_BALANCE% (version xid=%xid%).', { WRITTEN_BALANCE: WRITTEN_BALANCE, xid: txnW.xid }), 'write-msg');
phase = 2;
document.getElementById('btnW').disabled = true;
document.getElementById('btnB').disabled = false;
});
document.getElementById('btnB').addEventListener('click', () => {
if (phase !== 2) return;
txnB = { xid: txnIdCounter++, snapshotMaxXid: txnIdCounter };
step('', '', '', fmt('START (xid=%xid%, snapshot<%snapshotMaxXid%)', { xid: txnB.xid, snapshotMaxXid: txnB.snapshotMaxXid }));
log(fmt('Txn B started (xid=%xid%). Snapshot: sees versions with xid < %snapshotMaxXid%.', { xid: txnB.xid, snapshotMaxXid: txnB.snapshotMaxXid }), 'info');
phase = 3;
document.getElementById('btnB').disabled = true;
document.getElementById('readA').disabled = false;
document.getElementById('readB').disabled = false;
});
let readADone = false, readBDone = false;
document.getElementById('readA').addEventListener('click', () => {
if (phase !== 3 || readADone) return;
const val = readSnapshot(txnA);
readADone = true;
rows.push({ tick: tick++, txnA: fmt('READ → $%val%', { val: val }), write: '', txnB: '', txnARead: val });
renderTimeline();
log(fmt('Txn A reads: $%val% (saw xid<%snapshotMaxXid% → old version). %v%', { val: val, snapshotMaxXid: txnA.snapshotMaxXid, v: val === INITIAL_BALANCE ? '✓ Correct — write was not in its snapshot.' : 'Unexpected!' }), 'ok');
document.getElementById('readA').disabled = true;
if (readBDone) phase = 4;
});
document.getElementById('readB').addEventListener('click', () => {
if (phase !== 3 || readBDone) return;
const val = readSnapshot(txnB);
readBDone = true;
rows.push({ tick: tick++, txnA: '', write: '', txnB: fmt('READ → $%val%', { val: val }), txnBRead: val });
renderTimeline();
log(fmt('Txn B reads: $%val% (saw xid<%snapshotMaxXid% → new version). %v%', { val: val, snapshotMaxXid: txnB.snapshotMaxXid, v: val === WRITTEN_BALANCE ? '✓ Correct — write was in its snapshot.' : 'Unexpected!' }), 'ok');
document.getElementById('readB').disabled = true;
if (readADone) phase = 4;
});
document.getElementById('reset').addEventListener('click', init);
init();
Comments
Loading comments...