-
Notifications
You must be signed in to change notification settings - Fork 215
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
testing: demonstrate expected isolation properties (#165)
Extend the unit tests to cover the expected isolation levels for avoiding dirty reads, non-repeatable reads, and phantom reads. These tests are intended to be demonstrative rather than exhaustive. They provide a guide for developers as to what behaviors to expect from applications consuming the library rather than robustly verifying the behavior under load and high concurrency. That is, they can show that an implementation is grossly incorrect but cannot show that an implementation is correct.
- Loading branch information
Showing
1 changed file
with
322 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,322 @@ | ||
// Copyright (c) HashiCorp, Inc. | ||
// SPDX-License-Identifier: MPL-2.0 | ||
|
||
package memdb | ||
|
||
import ( | ||
"testing" | ||
) | ||
|
||
func TestMemDB_Isolation(t *testing.T) { | ||
|
||
id1 := "object-one" | ||
id2 := "object-two" | ||
id3 := "object-three" | ||
|
||
mustNoError := func(t *testing.T, err error) { | ||
if err != nil { | ||
t.Fatalf("unexpected test error: %v", err) | ||
} | ||
} | ||
|
||
setup := func(t *testing.T) *MemDB { | ||
t.Helper() | ||
|
||
db, err := NewMemDB(testValidSchema()) | ||
if err != nil { | ||
t.Fatalf("err: %v", err) | ||
} | ||
|
||
// Add two objects (with a gap between their IDs) | ||
obj1a := testObj() | ||
obj1a.ID = id1 | ||
txn := db.Txn(true) | ||
mustNoError(t, txn.Insert("main", obj1a)) | ||
|
||
obj3 := testObj() | ||
obj3.ID = id3 | ||
mustNoError(t, txn.Insert("main", obj3)) | ||
txn.Commit() | ||
return db | ||
} | ||
|
||
t.Run("snapshot dirty read", func(t *testing.T) { | ||
db := setup(t) | ||
db2 := db.Snapshot() | ||
|
||
// Update an object | ||
obj1b := testObj() | ||
obj1b.ID = id1 | ||
txn1 := db.Txn(true) | ||
obj1b.Baz = "nope" | ||
mustNoError(t, txn1.Insert("main", obj1b)) | ||
|
||
// Insert an object | ||
obj2 := testObj() | ||
obj2.ID = id2 | ||
mustNoError(t, txn1.Insert("main", obj2)) | ||
|
||
txn2 := db2.Txn(false) | ||
out, err := txn2.First("main", "id", id1) | ||
mustNoError(t, err) | ||
if out == nil { | ||
t.Fatalf("should exist") | ||
} | ||
if out.(*TestObject).Baz == "nope" { | ||
t.Fatalf("read from snapshot should not observe uncommitted update (dirty read)") | ||
} | ||
|
||
out, err = txn2.First("main", "id", id2) | ||
mustNoError(t, err) | ||
if out != nil { | ||
t.Fatalf("read from snapshot should not observe uncommitted insert (dirty read)") | ||
} | ||
|
||
// New snapshot should not observe uncommitted writes | ||
db3 := db.Snapshot() | ||
txn3 := db3.Txn(false) | ||
out, err = txn3.First("main", "id", id1) | ||
mustNoError(t, err) | ||
if out == nil { | ||
t.Fatalf("should exist") | ||
} | ||
if out.(*TestObject).Baz == "nope" { | ||
t.Fatalf("read from new snapshot should not observe uncommitted writes") | ||
} | ||
}) | ||
|
||
t.Run("transaction dirty read", func(t *testing.T) { | ||
db := setup(t) | ||
|
||
// Update an object | ||
obj1b := testObj() | ||
obj1b.ID = id1 | ||
txn1 := db.Txn(true) | ||
obj1b.Baz = "nope" | ||
mustNoError(t, txn1.Insert("main", obj1b)) | ||
|
||
// Insert an object | ||
obj2 := testObj() | ||
obj2.ID = id2 | ||
mustNoError(t, txn1.Insert("main", obj2)) | ||
|
||
txn2 := db.Txn(false) | ||
out, err := txn2.First("main", "id", id1) | ||
mustNoError(t, err) | ||
if out == nil { | ||
t.Fatalf("should exist") | ||
} | ||
if out.(*TestObject).Baz == "nope" { | ||
t.Fatalf("read from transaction should not observe uncommitted update (dirty read)") | ||
} | ||
|
||
out, err = txn2.First("main", "id", id2) | ||
mustNoError(t, err) | ||
if out != nil { | ||
t.Fatalf("read from transaction should not observe uncommitted insert (dirty read)") | ||
} | ||
}) | ||
|
||
t.Run("snapshot non-repeatable read", func(t *testing.T) { | ||
db := setup(t) | ||
db2 := db.Snapshot() | ||
|
||
// Update an object | ||
obj1b := testObj() | ||
obj1b.ID = id1 | ||
txn1 := db.Txn(true) | ||
obj1b.Baz = "nope" | ||
mustNoError(t, txn1.Insert("main", obj1b)) | ||
|
||
// Insert an object | ||
obj2 := testObj() | ||
obj2.ID = id3 | ||
mustNoError(t, txn1.Insert("main", obj2)) | ||
|
||
// Commit | ||
txn1.Commit() | ||
|
||
txn2 := db2.Txn(false) | ||
out, err := txn2.First("main", "id", id1) | ||
mustNoError(t, err) | ||
if out == nil { | ||
t.Fatalf("should exist") | ||
} | ||
if out.(*TestObject).Baz == "nope" { | ||
t.Fatalf("read from snapshot should not observe committed write from another transaction (non-repeatable read)") | ||
} | ||
|
||
out, err = txn2.First("main", "id", id2) | ||
mustNoError(t, err) | ||
if out != nil { | ||
t.Fatalf("read from snapshot should not observe committed write from another transaction (non-repeatable read)") | ||
} | ||
|
||
}) | ||
|
||
t.Run("transaction non-repeatable read", func(t *testing.T) { | ||
db := setup(t) | ||
|
||
// Update an object | ||
obj1b := testObj() | ||
obj1b.ID = id1 | ||
txn1 := db.Txn(true) | ||
obj1b.Baz = "nope" | ||
mustNoError(t, txn1.Insert("main", obj1b)) | ||
|
||
// Insert an object | ||
obj2 := testObj() | ||
obj2.ID = id3 | ||
mustNoError(t, txn1.Insert("main", obj2)) | ||
|
||
txn2 := db.Txn(false) | ||
|
||
// Commit | ||
txn1.Commit() | ||
|
||
out, err := txn2.First("main", "id", id1) | ||
mustNoError(t, err) | ||
if out == nil { | ||
t.Fatalf("should exist") | ||
} | ||
if out.(*TestObject).Baz == "nope" { | ||
t.Fatalf("read from transaction should not observe committed write from another transaction (non-repeatable read)") | ||
} | ||
|
||
out, err = txn2.First("main", "id", id2) | ||
mustNoError(t, err) | ||
if out != nil { | ||
t.Fatalf("read from transaction should not observe committed write from another transaction (non-repeatable read)") | ||
} | ||
|
||
}) | ||
|
||
t.Run("snapshot phantom read", func(t *testing.T) { | ||
db := setup(t) | ||
db2 := db.Snapshot() | ||
|
||
txn2 := db2.Txn(false) | ||
iter, err := txn2.Get("main", "id_prefix", "object") | ||
mustNoError(t, err) | ||
out := iter.Next() | ||
if out == nil || out.(*TestObject).ID != id1 { | ||
t.Fatal("missing expected object 'object-one'") | ||
} | ||
|
||
// Insert an object and commit | ||
txn1 := db.Txn(true) | ||
obj2 := testObj() | ||
obj2.ID = id2 | ||
mustNoError(t, txn1.Insert("main", obj2)) | ||
txn1.Commit() | ||
|
||
out = iter.Next() | ||
if out == nil { | ||
t.Fatal("expected 2 objects") | ||
} | ||
if out.(*TestObject).ID == id2 { | ||
t.Fatalf("read from snapshot should not observe new objects in set (phantom read)") | ||
} | ||
|
||
out = iter.Next() | ||
if out != nil { | ||
t.Fatal("expected only 2 objects: read from snapshot should not observe new objects in set (phantom read)") | ||
} | ||
|
||
// Remove an object using an outdated pointer | ||
txn1 = db.Txn(true) | ||
obj1, err := txn1.First("main", "id", id1) | ||
mustNoError(t, err) | ||
mustNoError(t, txn1.Delete("main", obj1)) | ||
txn1.Commit() | ||
|
||
iter, err = txn2.Get("main", "id_prefix", "object") | ||
mustNoError(t, err) | ||
|
||
out = iter.Next() | ||
if out == nil || out.(*TestObject).ID != id1 { | ||
t.Fatal("missing expected object 'object-one': read from snapshot should not observe deletes (phantom read)") | ||
} | ||
out = iter.Next() | ||
if out == nil || out.(*TestObject).ID != id3 { | ||
t.Fatal("missing expected object 'object-three': read from snapshot should not observe deletes (phantom read)") | ||
} | ||
|
||
}) | ||
|
||
t.Run("transaction phantom read", func(t *testing.T) { | ||
db := setup(t) | ||
|
||
txn2 := db.Txn(false) | ||
iter, err := txn2.Get("main", "id_prefix", "object") | ||
mustNoError(t, err) | ||
out := iter.Next() | ||
if out == nil || out.(*TestObject).ID != id1 { | ||
t.Fatal("missing expected object 'object-one'") | ||
} | ||
|
||
// Insert an object and commit | ||
txn1 := db.Txn(true) | ||
obj2 := testObj() | ||
obj2.ID = id2 | ||
mustNoError(t, txn1.Insert("main", obj2)) | ||
txn1.Commit() | ||
|
||
out = iter.Next() | ||
if out == nil { | ||
t.Fatal("expected 2 objects") | ||
} | ||
if out.(*TestObject).ID == id2 { | ||
t.Fatalf("read from transaction should not observe new objects in set (phantom read)") | ||
} | ||
|
||
out = iter.Next() | ||
if out != nil { | ||
t.Fatal("expected only 2 objects: read from transaction should not observe new objects in set (phantom read)") | ||
} | ||
|
||
// Remove an object using an outdated pointer | ||
txn1 = db.Txn(true) | ||
obj1, err := txn1.First("main", "id", id1) | ||
mustNoError(t, err) | ||
mustNoError(t, txn1.Delete("main", obj1)) | ||
txn1.Commit() | ||
|
||
iter, err = txn2.Get("main", "id_prefix", "object") | ||
if err != nil { | ||
t.Fatalf("err: %v", err) | ||
} | ||
|
||
out = iter.Next() | ||
if out == nil || out.(*TestObject).ID != id1 { | ||
t.Fatal("missing expected object 'object-one': read from transaction should not observe deletes (phantom read)") | ||
} | ||
out = iter.Next() | ||
if out == nil || out.(*TestObject).ID != id3 { | ||
t.Fatal("missing expected object 'object-three': read from transaction should not observe deletes (phantom read)") | ||
} | ||
|
||
}) | ||
|
||
t.Run("snapshot commits are unobservable", func(t *testing.T) { | ||
db := setup(t) | ||
db2 := db.Snapshot() | ||
|
||
txn2 := db2.Txn(true) | ||
obj1 := testObj() | ||
obj1.ID = id1 | ||
obj1.Baz = "also" | ||
mustNoError(t, txn2.Insert("main", obj1)) | ||
txn2.Commit() | ||
|
||
txn1 := db.Txn(false) | ||
out, err := txn1.First("main", "id", id1) | ||
mustNoError(t, err) | ||
if out == nil { | ||
t.Fatalf("should exist") | ||
} | ||
if out.(*TestObject).Baz == "also" { | ||
t.Fatalf("commit from snapshot should never be observed") | ||
} | ||
}) | ||
} |