Skip to content

Commit

Permalink
snapshot isolation level
Browse files Browse the repository at this point in the history
if the current transaction A's write set intersects with any other transaction B committed since transaction A started, we must abort.
  • Loading branch information
mukeshjc committed Jan 18, 2025
1 parent aa34cf1 commit ef52427
Show file tree
Hide file tree
Showing 2 changed files with 103 additions and 0 deletions.
28 changes: 28 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,31 @@ func TestRepeatableRead(t *testing.T) {
utils.AssertEq(res, "", "c5 get x")
utils.AssertEq(err.Error(), "cannot get key that doesn't exist", "c5 get x")
}

// Snapshot Isolation shares all the same visibility rules as Repeatable Read, the tests get to be a little simpler!
// We'll simply test that two transactions attempting to commit a write to the same key fail. Or specifically: that the second transaction cannot commit.
func TestSnapshotIsolation(t *testing.T) {
database := mvcc.NewDatabase(mvcc.SnapshotIsolation)

c1 := database.NewConnection()
c1.MustExecCommand("begin", nil)

c2 := database.NewConnection()
c2.MustExecCommand("begin", nil)

c3 := database.NewConnection()
c3.MustExecCommand("begin", nil)

c1.MustExecCommand("set", []string{"x", "hey"})
c1.MustExecCommand("commit", nil)

c2.MustExecCommand("set", []string{"x", "hey"})

res, err := c2.ExecCommand("commit", nil)
utils.AssertEq(res, "", "c2 commit")
utils.AssertEq(err.Error(), "write-write conflict", "c2 commit")

// But unrelated keys cause no conflict.
c3.MustExecCommand("set", []string{"y", "no conflict"})
c3.MustExecCommand("commit", nil)
}
75 changes: 75 additions & 0 deletions mvcc/database.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package mvcc

import (
"fmt"

"github.com/tidwall/btree"

"github.com/mukeshjc/mvcc-isolation/v2/utils"
Expand Down Expand Up @@ -73,6 +75,18 @@ func (d *Database) newTransaction() *Transaction {
func (d *Database) completeTransaction(t *Transaction, state TransactionState) error {
utils.Debug("completing transaction ", t.id)

if state == CommittedTransaction {
// Snapshot Isolation is the same as Repeatable Read but with one additional rule: the keys written by any two concurrent committed transactions must not overlap.
if t.isolation == SnapshotIsolation {
if d.hasConflict(t, func(t1 *Transaction, t2 *Transaction) bool {
return setsShareKeys(t1.writeset, t2.writeset)
}) {
d.completeTransaction(t, RolledBackTransaction)
return fmt.Errorf("write-write conflict")
}
}
}

// update transactions.
t.state = state
d.transactions.Set(t.id, *t)
Expand Down Expand Up @@ -133,6 +147,7 @@ func (d *Database) isVisible(t *Transaction, value Value) bool {

// Repeatable Read, Snapshot Isolation and Serializable further restricts Read Committed so only versions from transactions that completed before this one started are visible.
// we will add additional checks for the Read Committed logic that make sure the value was not created and not deleted within a transaction that started before this transaction started.
// https://jepsen.io/consistency/models/repeatable-read
// As it happens, this is the same logic that will be necessary for Snapshot Isolation and Serializable Isolation.
// The additional logic (that makes Snapshot Isolation and Serializable Isolation different) happens at commit time.

Expand Down Expand Up @@ -175,3 +190,63 @@ func (d *Database) isVisible(t *Transaction, value Value) bool {

return true
}

// In a snapshot isolated system, each transaction appears to operate on an independent, consistent snapshot of the database.
// Its changes are visible only to that transaction until commit time, when all changes become visible atomically to any transaction which begins at a later time.
// If transaction T1 has modified an object x, and another transaction T2 committed a write to x after T1’s snapshot began, and before T1’s commit, then T1 must abort.
// Snapshot Isolation is the same as Repeatable Read but with one additional rule: the keys written by any two concurrent committed transactions must not overlap.
// https://jepsen.io/consistency/models/snapshot-isolation

// when a transaction A goes to commit, it will run a conflict test for any transaction B that has committed since this transaction A started to check for clashes in the keys written

// a helper for iterating through all relevant transactions, running a check function for any transaction that has committed.
func (d *Database) hasConflict(t1 *Transaction, conflictFn func(*Transaction, *Transaction) bool) bool {
iter := d.transactions.Iter()

// first see if there is any conflict with transactions that were in progress when this one started.
inprogressIter := t1.inprogress.Iter()
for ok := inprogressIter.First(); ok; ok = inprogressIter.Next() {
id := inprogressIter.Key()
found := iter.Seek(id)
if !found {
continue
}
t2 := iter.Value()
if t2.state == CommittedTransaction {
if conflictFn(t1, &t2) {
return true
}
}
}

// then see if there is any conflict with transactions that started and committed after this one started.
for id := t1.id; id < d.nextTransactionId; id++ {
found := iter.Seek(id)
if !found {
continue
}
t2 := iter.Value()
if t2.state == CommittedTransaction {
if conflictFn(t1, &t2) {
return true
}
}
}

return false
}

func setsShareKeys(s1 btree.Set[string], s2 btree.Set[string]) bool {
s1Iter := s1.Iter()
s2Iter := s2.Iter()

for ok := s1Iter.First(); ok; ok = s1Iter.Next() {
s1Key := s1Iter.Key()
found := s2Iter.Seek(s1Key)
if found {
return true
}
}

return false
}

0 comments on commit ef52427

Please sign in to comment.