From b3f3099b74870b221d95e23d5419a8da9e7764ec Mon Sep 17 00:00:00 2001 From: Benjamin Bengfort Date: Wed, 26 Jan 2022 16:17:07 -0600 Subject: [PATCH] Skip, stomp, and linear version history tracking in Update (#19) Adds an UpdateType return value to the db.Update method so callers can track what happens to the underlying version history. The types are directly connected to new Version comparison methods of similar names, which will hopefully make it easier for us to reason about whats happening in the version history and in anti-entropy in general. --- honu.go | 85 +++++++++++++++--- honu_test.go | 201 +++++++++++++++++++++++++++++++++++------- object/object.go | 67 +++++++++++++- object/object_test.go | 170 +++++++++++++++++++++++++++++++++-- 4 files changed, 466 insertions(+), 57 deletions(-) diff --git a/honu.go b/honu.go index f8f4cdb..7add85b 100644 --- a/honu.go +++ b/honu.go @@ -119,31 +119,71 @@ func (db *DB) Get(key []byte, options ...opts.Option) (value []byte, err error) // Return the wrapped data return obj.Data, nil +} + +// UpdateType is an intermediate solution to tracking what's happening to the version +// history when direct modifications are applied to the database. +// NOTE: this data type is subject to change in later versions and should be treated +// as a prototype only in production code. +type UpdateType uint8 +const ( + UpdateNoChange UpdateType = iota // No change occurred (nothing was written to disk) + UpdateForced // The update was forced, so the previous version was not checked + UpdateLinear // The previous version is the parent of the updating version + UpdateStomp // The previous version is concurrent but has a lower precedence than the updating version + UpdateSkip // The previous version is later but is not concurrent nor linear from the updating version + +) + +func (u UpdateType) String() string { + switch u { + case UpdateNoChange: + return "unchanged" + case UpdateForced: + return "forced" + case UpdateLinear: + return "linear" + case UpdateStomp: + return "stomped" + case UpdateSkip: + return "skipped" + default: + return "unknown" + } } // Update an object directly in the database without modifying its version information. // Update is to Put as Object is to Get - use Update when manually modifying the data -// store, for example during replication, but not for normal DB operations. -func (db *DB) Update(obj *pb.Object, options ...opts.Option) (err error) { +// store, for example during replication, but not for normal DB operations. Update also +// returns the type of update that ocurred, relative to the previous version. +func (db *DB) Update(obj *pb.Object, options ...opts.Option) (update UpdateType, err error) { var tx engine.Transaction if tx, err = db.engine.Begin(false); err != nil { - return err + return UpdateNoChange, err } defer tx.Finish() // Collect the options var cfg *opts.Options if cfg, err = opts.New(options...); err != nil { - return err + return UpdateNoChange, err + } + + // If the default namespace is specified use the object's namespace to ensure that + // if the user did not supply Namespace, Update still works. If the object was + // already in the default namespace, this should not cause a change to happen. There + // is an edge case where the user supplies options.WithNamespace("default") and an + // object that is not in the default namespace and the user option will be ignored + // in favor of the object's original namespace; but this is an unlikely case. + if cfg.Namespace == opts.NamespaceDefault { + cfg.Namespace = obj.Namespace } if !cfg.Force { // Check the namespace and that it matches the object - if cfg.Namespace == opts.NamespaceDefault { - cfg.Namespace = obj.Namespace - } else if cfg.Namespace != obj.Namespace { - return errors.New("options namespace does not match object namespace") + if cfg.Namespace != obj.Namespace { + return UpdateNoChange, errors.New("options namespace does not match object namespace") } // Check that the version is later than the version being written to disk @@ -153,29 +193,46 @@ func (db *DB) Update(obj *pb.Object, options ...opts.Option) (err error) { ) if prevData, err = tx.Get(obj.Key, cfg); err != nil { if !errors.Is(err, engine.ErrNotFound) { - return fmt.Errorf("could not check previous version: %v", err) + return UpdateNoChange, fmt.Errorf("could not check previous version: %v", err) } } else { if err = proto.Unmarshal(prevData, prev); err != nil { - return fmt.Errorf("could not unmarshal previous version: %v", err) + return UpdateNoChange, fmt.Errorf("could not unmarshal previous version: %v", err) } } if !obj.Version.IsLater(prev.Version) { - return fmt.Errorf("cannot update object, it is not a later version then the current object") + return UpdateNoChange, fmt.Errorf("cannot update object, it is not a later version then the current object") + } + + // Determine the update type based on the previous version + // NOTE: all update conditions imply the current version is later than previous + switch { + case obj.Version.Stomps(prev.Version): + update = UpdateStomp + case obj.Version.Skips(prev.Version): + update = UpdateSkip + case obj.Version.LinearFrom(prev.Version): + update = UpdateLinear + default: + return UpdateNoChange, fmt.Errorf("cannot determine update relationship in the version history") } + + } else { + // Report that the update was forced + update = UpdateForced } // Put the version directly to disk var data []byte if data, err = proto.Marshal(obj); err != nil { - return err + return UpdateNoChange, err } if err = tx.Put(obj.Key, data, cfg); err != nil { - return err + return UpdateNoChange, err } - return nil + return update, nil } // Put a new value to the specified key and update the version. diff --git a/honu_test.go b/honu_test.go index 791440b..a58c9b4 100644 --- a/honu_test.go +++ b/honu_test.go @@ -1,13 +1,16 @@ package honu_test import ( + "bytes" "fmt" "io/ioutil" + "math/rand" "os" "testing" "github.com/rotationalio/honu" "github.com/rotationalio/honu/config" + "github.com/rotationalio/honu/object" "github.com/rotationalio/honu/options" "github.com/stretchr/testify/require" ) @@ -108,7 +111,7 @@ func TestLevelDBInteractions(t *testing.T) { require.Equal(t, uint64(3), obj.Version.Version) require.False(t, obj.Tombstone()) - // Attempt to directly update the object in the database + // Attempt to directly update the object in the database with a later version obj.Data = []byte("directly updated") obj.Owner = "me" obj.Version.Parent = nil @@ -116,38 +119,8 @@ func TestLevelDBInteractions(t *testing.T) { obj.Version.Pid = 93 obj.Version.Region = "here" obj.Version.Tombstone = false - require.NoError(t, db.Update(obj)) - - obj, err = db.Object(key, options.WithNamespace(namespace)) + _, err = db.Update(obj) require.NoError(t, err) - require.Equal(t, uint64(42), obj.Version.Version) - require.Equal(t, uint64(93), obj.Version.Pid) - require.Equal(t, "me", obj.Owner) - require.Equal(t, "here", obj.Version.Region) - - // Update with same namespace option should not error. - obj.Version.Version = 43 - require.NoError(t, db.Update(obj, options.WithNamespace(namespace))) - - // Update with wrong namespace should error - require.Error(t, db.Update(obj, options.WithNamespace("this is not the right thing"))) - - // Update with wrong namespace but with force should not error. - require.NoError(t, db.Update(obj, options.WithNamespace("trashcan"), options.WithForce())) - - // Update with the same version should error. - require.Error(t, db.Update(obj, options.WithNamespace(namespace))) - - // Update with an earlier version should error - obj.Version.Version = 7 - require.Error(t, db.Update(obj, options.WithNamespace(namespace))) - - // Update with an earlier version with force should not error. - require.NoError(t, db.Update(obj, options.WithNamespace(namespace), options.WithForce())) - - // Update an object that does not exist should not error. - obj.Key = []byte("secretobjinvisible") - require.NoError(t, db.Update(obj, options.WithNamespace(namespace))) // TODO: figure out what to do with this testcase. // Iter currently grabs the namespace by splitting @@ -189,3 +162,167 @@ func TestLevelDBInteractions(t *testing.T) { iter.Release() } } + +func TestUpdate(t *testing.T) { + // Create a test database to attempt to update + db, tmpDir := setupHonuDB(t) + + // Cleanup when we're done with the test + defer os.RemoveAll(tmpDir) + defer db.Close() + + // Create a random object in the database to start update tests on + key := randomData(32) + namespace := "yeti" + root, err := db.Put(key, randomData(96), options.WithNamespace(namespace)) + require.NoError(t, err, "could not put random data") + + // Generate new1 - a linear object from root as though it were from a different replica + new1 := &object.Object{ + Key: key, + Namespace: namespace, + Version: &object.Version{ + Pid: 113, + Version: 2, + Parent: root.Version, + }, + Region: "the-void", + Owner: root.Owner, + Data: randomData(112), + } + + // Should be able to update with no namespace option + update, err := db.Update(new1) + require.NoError(t, err, "could not update db with new1") + require.Equal(t, honu.UpdateLinear, update, "expected new1 update to be linear") + requireObjectEqual(t, db, new1, key, namespace) + + // Should not be be able to update with the same version twice, since it is now no + // longer later than previous version (it is the equal version on disk). + update, err = db.Update(new1) + require.EqualError(t, err, "cannot update object, it is not a later version then the current object") + require.Equal(t, honu.UpdateNoChange, update) + + // Should be able to force the update to apply the same object back to disk. + update, err = db.Update(new1, options.WithForce()) + require.NoError(t, err, "could not force update with new1") + require.Equal(t, honu.UpdateForced, update) + + // Generate new2 - an object stomping new1 as though it were from a different replica + new2 := &object.Object{ + Key: key, + Namespace: namespace, + Version: &object.Version{ + Pid: 42, + Version: 2, + Parent: root.Version, + }, + Region: "the-other-void", + Owner: root.Owner, + Data: randomData(112), + } + + // Update with the wrong namespace should error + update, err = db.Update(new2, options.WithNamespace("this is not the right namespace for sure")) + require.EqualError(t, err, "options namespace does not match object namespace") + require.Equal(t, honu.UpdateNoChange, update) + requireObjectEqual(t, db, new1, key, namespace) + + // Update with the wrong namespace but with force should not error and create a new object + // NOTE: this is kind of a wild force since now the object has the wrong namespace metadata. + update, err = db.Update(new2, options.WithNamespace("trashcan"), options.WithForce()) + require.NoError(t, err) + require.Equal(t, honu.UpdateForced, update) + requireObjectEqual(t, db, new1, key, namespace) + requireObjectEqual(t, db, new2, key, "trashcan") + + // Update with same namespace option should not error. + update, err = db.Update(new2, options.WithNamespace(namespace)) + require.NoError(t, err, "could not update new2") + require.Equal(t, honu.UpdateStomp, update) + requireObjectEqual(t, db, new2, key, namespace) + + // Generate new3 - an object skipping new2 as though it were from the same replica + new3 := &object.Object{ + Key: key, + Namespace: namespace, + Version: &object.Version{ + Pid: 42, + Version: 12, + Parent: root.Version, + }, + Region: "the-other-void", + Owner: root.Owner, + Data: randomData(112), + } + + // Ensure UpdateSkip is returned + update, err = db.Update(new3) + require.NoError(t, err, "could not update new3") + require.Equal(t, honu.UpdateSkip, update) + requireObjectEqual(t, db, new3, key, namespace) + + // Update with an earlier version should error + update, err = db.Update(new1) + require.EqualError(t, err, "cannot update object, it is not a later version then the current object") + require.Equal(t, honu.UpdateNoChange, update) + requireObjectEqual(t, db, new3, key, namespace) + + // Should be able to force the update to apply the earlier object back to disk + update, err = db.Update(new1, options.WithForce()) + require.NoError(t, err, "could not force update with new1") + require.Equal(t, honu.UpdateForced, update) + requireObjectEqual(t, db, new1, key, namespace) + + // Update an object that does not exist should not error. + stranger := &object.Object{ + Key: randomData(18), + Namespace: "default", + Version: &object.Version{ + Pid: 1, + Version: 1, + Parent: nil, + }, + Region: "the-void", + Owner: "me", + Data: randomData(8), + } + + update, err = db.Update(stranger) + require.NoError(t, err) + require.Equal(t, honu.UpdateLinear, update) +} + +// Helper assertion function to check to make sure an object matches what is in the database +func requireObjectEqual(t *testing.T, db *honu.DB, expected *object.Object, key []byte, namespace string) { + actual, err := db.Object(key, options.WithNamespace(namespace)) + require.NoError(t, err, "could not fetch expected object from the database") + + // NOTE: we cannot do a require.Equal(t, expected, actual) because the test will hang + // it's not clear if there is a recursive loop with version comparisons or some other + // deep equality is causing the problem. Instead we directly compare the data. + require.True(t, bytes.Equal(expected.Key, actual.Key), "key is not equal") + require.Equal(t, expected.Namespace, actual.Namespace, "namespace not equal") + require.Equal(t, expected.Region, actual.Region, "region not equal") + require.Equal(t, expected.Owner, actual.Owner, "owner not equal") + require.True(t, expected.Version.Equal(actual.Version), "versions not equal") + require.Equal(t, expected.Version.Region, actual.Version.Region, "version region not equal") + require.Equal(t, expected.Version.Tombstone, actual.Version.Tombstone, "version tombstone not the same") + if expected.Version.Parent != nil { + require.True(t, expected.Version.Parent.Equal(actual.Version.Parent), "parents not equal") + require.Equal(t, expected.Version.Parent.Region, actual.Version.Parent.Region, "parent regions not equal") + require.Equal(t, expected.Version.Parent.Tombstone, actual.Version.Parent.Tombstone, "parent tombstone not the same") + } else { + require.Nil(t, actual.Version.Parent, "expected parent is nil") + } + require.True(t, bytes.Equal(expected.Data, actual.Data), "value is not equal") +} + +// Helper function to generate random data +func randomData(len int) []byte { + data := make([]byte, len) + if _, err := rand.Read(data); err != nil { + panic(err) + } + return data +} diff --git a/object/object.go b/object/object.go index cb1f87e..3c48db4 100644 --- a/object/object.go +++ b/object/object.go @@ -17,7 +17,18 @@ func (v *Version) IsZero() bool { } // IsLater returns true if the specified version is later than the other version. It -// returns false if the other version is later or equal to the specified version. +// returns false if the other version is later or equal to the specified version. If +// other is nil, it is considered equivalent to the zero-valued version. +// +// Versions are Lamport Scalars composed of a monotonically increasing scalar component +// along with a tiebreaking component called the PID (the process ID of a replica). If +// the scalar is greater than the other scaler, then the Version is later. If the +// scalars are equal, then the version with the lower PID has higher precedence. +// Versions are conflict free so long as the processes that increment the scalar have a +// unique PID. E.g. if two processes concurrently increment the scalar to the same value +// then unique PIDs guarantee that one of those processes will have precedence. Lower +// PIDs are used for the higher precedence because of the PIDs are assigned in +// increasing order, then the lower PIDs are older replicas. func (v *Version) IsLater(other *Version) bool { // If other is nil, then we assume it represents the zero-valued version. if other == nil { @@ -35,10 +46,62 @@ func (v *Version) IsLater(other *Version) bool { return true } - // Either v.Version < other.Version or other.Pid > v.Pid + // Either v.Version < other.Version, other.Pid > v.Pid, or the versions are equal. return false } +// Equal returns true if and only if the Version scalars and PIDs are equal. Nil is +// considered to be the zero valued Version. +func (v *Version) Equal(other *Version) bool { + // If other is nil, then we assume it represents the zero-valued version. + if other == nil { + other = &VersionZero + } + return v.Version == other.Version && v.Pid == other.Pid +} + +// Concurrent returns true if and only if the Version scalars are equal but the PIDs are +// not equal. Nil is considered to be the zero valued Version. +func (v *Version) Concurrent(other *Version) bool { + // If other is nil, then we assume it represents the zero-valued version. + if other == nil { + other = &VersionZero + } + return v.Version == other.Version && v.Pid != other.Pid +} + +// LinearFrom returns true if and only if the the parent of this version is equal to the +// other version. E.g. is this version created from the other version (a child of). +// LinearFrom implies that this version is later than the other version so long as the +// child is always later than the parent version. +// +// NOTE: this method cannot detect a linear chain through multiple ancestors to the +// current version - it is a direct relationship only. A complete version history is +// required to compute a longer linear chain and branch points. +func (v *Version) LinearFrom(other *Version) bool { + // Handle the case of the root version (no parent) + if v.Parent == nil { + return other == nil || other.IsZero() + } + return v.Parent.Equal(other) +} + +// Stomps returns true if and only if this version is both concurrent and later than the +// other version; e.g. this version is concurrent and would have precedence. +func (v *Version) Stomps(other *Version) bool { + return v.Concurrent(other) && v.IsLater(other) +} + +// Skips returns true if and only if this version is later, is not concurrent (e.g. is +// not a stomp) and is not linear from the other version, e.g. this version is at least +// one version farther in the version history than the other version. Skips does not +// imply a stomp, though a stomp is possible in the version chain between the skip. +// Skips really mean that the replica does not have enough information to determine if +// a stomp has occurred or if we've just moved forward in a linear chain. +func (v *Version) Skips(other *Version) bool { + return v.IsLater(other) && !v.Concurrent(other) && !v.LinearFrom(other) +} + //Copies the child's attributes before updating to the parent. func (v *Version) Clone() *Version { parent := &Version{ diff --git a/object/object_test.go b/object/object_test.go index a311e40..e618ca6 100644 --- a/object/object_test.go +++ b/object/object_test.go @@ -25,14 +25,166 @@ func TestTombstone(t *testing.T) { } func TestVersionIsLater(t *testing.T) { + // Create a version with non-zero values to test against v1 := &Version{Pid: 8, Version: 42} - require.True(t, v1.IsLater(nil)) - require.True(t, v1.IsLater(&VersionZero)) - require.False(t, VersionZero.IsLater(v1)) - require.False(t, VersionZero.IsLater(nil)) - require.False(t, VersionZero.IsLater(&VersionZero)) - require.True(t, v1.IsLater(&Version{Pid: 8, Version: 40})) - require.True(t, v1.IsLater(&Version{Pid: 9, Version: 42})) - require.False(t, v1.IsLater(&Version{Pid: 7, Version: 42})) - require.False(t, v1.IsLater(&Version{Pid: 8, Version: 44})) + + // Tests against version zero + require.True(t, v1.IsLater(&VersionZero), "version should be later than zero value") + require.True(t, v1.IsLater(nil), "version should treat nil as zero value") + require.False(t, VersionZero.IsLater(v1), "zero value should not be later than version") + + require.False(t, VersionZero.IsLater(&VersionZero), "zero value should not be later than itself") + require.False(t, VersionZero.IsLater(nil), "zero value should not be later than nil") + + // Test against other versions + require.True(t, v1.IsLater(&Version{Pid: 8, Version: 40}), "version should be later than version with lesser scalar but same pid") + require.True(t, v1.IsLater(&Version{Pid: 9, Version: 42}), "concurrent version should be later than lower precedence pid") + require.False(t, v1.IsLater(&Version{Pid: 7, Version: 42}), "concurrent version should not be later than higher precedence pid") + require.False(t, v1.IsLater(&Version{Pid: 8, Version: 44}), "version should not be later than version with greater scaler but same pid") + require.False(t, v1.IsLater(&Version{Pid: 8, Version: 42}), "version should not be later than an equal version") +} + +func TestVersionEqual(t *testing.T) { + // Test if a version is equal to an identical version + v1 := &Version{Pid: 8, Version: 42} + v2 := &Version{Pid: 8, Version: 42} + require.True(t, v1.Equal(v2), "two versions with same PID and scalar should be equal") + require.True(t, v2.Equal(v1), "version equality should be reciprocal") + require.True(t, v1.Equal(v1), "a version object should be equal to itself") + + // Test no equality + require.False(t, v1.Equal(&VersionZero), "a version should not be equal to zero value") + require.False(t, v1.Equal(nil), "a version should not be equal to nil") + require.False(t, VersionZero.Equal(v2), "zero value should not be equal to a version") + require.False(t, v1.Equal(&Version{Pid: 9, Version: 42}), "versions with different PIDs should not be equal") + require.False(t, v1.Equal(&Version{Pid: 9, Version: 41}), "versions with different PIDs and scalars should not be equal") + require.False(t, v1.Equal(&Version{Pid: 8, Version: 43}), "versions with a greater scalar should not be equal") + require.False(t, v1.Equal(&Version{Pid: 8, Version: 41}), "versions with a lesser scalar should not be equal") + + // Test zero-versioned equality + require.True(t, VersionZero.Equal(nil), "zero value should equal nil") + require.True(t, VersionZero.Equal(&VersionZero), "zero value should equal zero value") +} + +func TestVersionConcurrent(t *testing.T) { + // Test two concurrent versions + v1 := &Version{Pid: 8, Version: 42} + v2 := &Version{Pid: 12, Version: 42} + + require.True(t, v1.Concurrent(v2), "versions should be concurrent with same scalar and different PID") + require.True(t, v2.Concurrent(v1), "concurrent should be reciprocal") + + // Equal, later, and earlier versions should not be concurrent even if they have the same PID + require.False(t, v1.Concurrent(&Version{Pid: 8, Version: 42}), "equal versions should not be concurrent") + require.False(t, v1.Concurrent(&Version{Pid: 12, Version: 48}), "later versions should not be concurrent") + require.False(t, v1.Concurrent(&Version{Pid: 12, Version: 21}), "earlier versions should not be concurrent") + require.False(t, v1.Concurrent(&VersionZero), "zero version should not be concurrent") + require.False(t, v1.Concurrent(nil), "nil should be treated as zero version") + + // Test zero-versioned concurrency + require.False(t, VersionZero.Concurrent(&VersionZero), "zero version should not be concurrent with itself") + require.False(t, VersionZero.Concurrent(nil), "zero version should not be concurrent with nil") + require.False(t, VersionZero.Concurrent(v1), "zero version should not be concurrent with another version") +} + +func TestLinearFrom(t *testing.T) { + // Root version + v1 := &Version{Pid: 8, Version: 1, Parent: nil} + + // Linear from v1 but with different PIDs + v2 := &Version{Pid: 8, Version: 2, Parent: &Version{Pid: 8, Version: 1}} + v3 := &Version{Pid: 9, Version: 2, Parent: &Version{Pid: 8, Version: 1}} + + // Linear from v3 + v4 := &Version{Pid: 9, Version: 3, Parent: &Version{Pid: 9, Version: 2}} + + require.True(t, v1.LinearFrom(&VersionZero), "root version should be linear from version zero") + require.True(t, v1.LinearFrom(nil), "should treat nil as the zero version") + require.False(t, v1.LinearFrom(v2), "parent should not be linear from child") + + require.True(t, v2.LinearFrom(v1), "version should be linear from its parent, same PID") + require.True(t, v3.LinearFrom(v1), "version should be linear from its parent, different PID") + require.True(t, v4.LinearFrom(v3), "a child should be linear from its parent") + + require.False(t, v2.LinearFrom(v3), "concurrent should not be linear from, other version") + require.False(t, v3.LinearFrom(v2), "stomp should not be linear from other version") + + require.False(t, v4.LinearFrom(v2), "a child should not be linear from stomped version") + require.False(t, v4.LinearFrom(v1), "a skip should not be linear from earlier version") + require.False(t, v1.LinearFrom(v4), "version should not be linear from later version") +} + +func TestStomps(t *testing.T) { + // Root version + v1 := &Version{Pid: 8, Version: 1, Parent: nil} + + require.False(t, v1.Stomps(&VersionZero), "root version should not stomp version zero") + require.False(t, v1.Stomps(nil), "should treat nil as the zero version") + require.False(t, v1.Stomps(&Version{Pid: 8, Version: 1}), "version should not stomp equal version") + + // Linear from v1 but with different PIDs (v2 stomps v3) + v2 := &Version{Pid: 8, Version: 2, Parent: &Version{Pid: 8, Version: 1}} + v3 := &Version{Pid: 9, Version: 2, Parent: &Version{Pid: 8, Version: 1}} + + require.True(t, v2.Stomps(v3), "stomps should be detected for concurrent versions") + require.False(t, v3.Stomps(v2), "only one concurrent version should stomp the other") + require.False(t, v2.Stomps(v2), "version should not stomp itself") + + // Linear from v3 (should not stomp v2 even though it's later) + v4 := &Version{Pid: 9, Version: 3, Parent: &Version{Pid: 9, Version: 2}} + require.False(t, v4.Stomps(v2), "later version should not stomp earlier version if not concurent") + require.False(t, v4.Stomps(v3), "version should not stomp its parent") +} + +func TestSkips(t *testing.T) { + // Root version + v1 := &Version{Pid: 8, Version: 1, Parent: nil} + + require.False(t, v1.Skips(&VersionZero), "root version should not skip zero value") + require.False(t, v1.Skips(nil), "should treat nil as the zero version") + require.False(t, v1.Skips(&Version{Pid: 8, Version: 1}), "version should not skip equal version") + + // Linear from v1 but with different PIDs (v2 stomps v3) + v2 := &Version{Pid: 8, Version: 2, Parent: &Version{Pid: 8, Version: 1}} + v3 := &Version{Pid: 9, Version: 2, Parent: &Version{Pid: 8, Version: 1}} + + require.False(t, v2.Skips(v1), "version should not skip its parent") + require.False(t, v2.Skips(v3), "stomp should not be a skip") + require.False(t, v3.Skips(v2), "concurrent version should not be a skip") + + // Linear from v3 (skips v3 from v1) + v4 := &Version{Pid: 9, Version: 3, Parent: &Version{Pid: 9, Version: 2}} + require.True(t, v4.Skips(v1), "a later version should skip an earlier version if parent is not equal") + require.True(t, v4.Skips(v2), "a branch should be considered a skip if the parent was stomped") + require.False(t, v4.Skips(v3), "a child should not skip a non-root parent") +} + +func TestVersionHistory(t *testing.T) { + // Create a version history without reusing pointers + v71 := &Version{Pid: 7, Version: 1, Parent: nil} + v72 := &Version{Pid: 7, Version: 2, Parent: &Version{Pid: 7, Version: 1}} + v73 := &Version{Pid: 7, Version: 3, Parent: &Version{Pid: 7, Version: 2}} + v83 := &Version{Pid: 8, Version: 3, Parent: &Version{Pid: 7, Version: 2}} + v74 := &Version{Pid: 7, Version: 4, Parent: &Version{Pid: 7, Version: 3}} + v84 := &Version{Pid: 8, Version: 4, Parent: &Version{Pid: 8, Version: 3}} + v85 := &Version{Pid: 8, Version: 5, Parent: &Version{Pid: 8, Version: 4}} + v95 := &Version{Pid: 9, Version: 5, Parent: &Version{Pid: 7, Version: 4}} + v76 := &Version{Pid: 7, Version: 6, Parent: &Version{Pid: 9, Version: 5}} + v97 := &Version{Pid: 9, Version: 7, Parent: &Version{Pid: 7, Version: 6}} + + // Test the 71 through 97 linear history + history := []*Version{v71, v72, v73, v74, v95, v76, v97} + for i := 1; i < len(history); i++ { + parent := history[i-1] + current := history[i] + require.True(t, current.LinearFrom(parent), "%s should be linear from %s", current, parent) + } + + // Test the 71 through 85 linear history + history = []*Version{v71, v72, v83, v84, v85} + for i := 1; i < len(history); i++ { + parent := history[i-1] + current := history[i] + require.True(t, current.LinearFrom(parent), "%s should be linear from %s", current, parent) + } }