Add versions to models (#1064)

## Description

Currently only the ModelStore populates and checks versions.

Some high-level points:
* fail any sort of get (just metadata or full model) if there's a version mismatch
* update operations automatically set things to the current version
* versions are stored as tags so there's some int->string (and vice versa) munging
* versions stored as tags so they can be repopulated even if only the metadata (BaseModel) for a model is pulled. This is done mostly for consistency

## Type of change

<!--- Please check the type of change your PR introduces: --->
- [x] 🌻 Feature
- [ ] 🐛 Bugfix
- [ ] 🗺️ Documentation
- [ ] 🤖 Test
- [ ] 💻 CI/Deployment
- [ ] 🐹 Trivial/Minor

## Issue(s)

* closes #284 

## Test Plan

<!-- How will this be tested prior to merging.-->
- [ ] 💪 Manual
- [x]  Unit test
- [ ] 💚 E2E
This commit is contained in:
ashmrtn 2022-10-05 17:46:49 -07:00 committed by GitHub
parent 78632f56e7
commit b9e33901f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 108 additions and 19 deletions

View File

@ -2,6 +2,7 @@ package kopia
import ( import (
"context" "context"
"strconv"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/kopia/kopia/repo" "github.com/kopia/kopia/repo"
@ -11,7 +12,11 @@ import (
"github.com/alcionai/corso/src/internal/model" "github.com/alcionai/corso/src/internal/model"
) )
const stableIDKey = "stableID" const (
stableIDKey = "stableID"
modelVersionKey = "storeVersion"
globalModelVersion = 1
)
var ( var (
ErrNotFound = errors.New("not found") ErrNotFound = errors.New("not found")
@ -27,12 +32,14 @@ func NewModelStore(c *conn) (*ModelStore, error) {
return nil, errors.Wrap(err, "creating ModelStore") return nil, errors.Wrap(err, "creating ModelStore")
} }
return &ModelStore{c: c}, nil return &ModelStore{c: c, modelVersion: globalModelVersion}, nil
} }
// ModelStore must not be accessed after the given KopiaWrapper is closed. // ModelStore must not be accessed after the given KopiaWrapper is closed.
type ModelStore struct { type ModelStore struct {
c *conn c *conn
// Stash a reference here so testing can easily change it.
modelVersion int
} }
func (ms *ModelStore) Close(ctx context.Context) error { func (ms *ModelStore) Close(ctx context.Context) error {
@ -70,6 +77,7 @@ func tagsForModel(s model.Schema, tags map[string]string) (map[string]string, er
func tagsForModelWithID( func tagsForModelWithID(
s model.Schema, s model.Schema,
id model.StableID, id model.StableID,
version int,
tags map[string]string, tags map[string]string,
) (map[string]string, error) { ) (map[string]string, error) {
if !s.Valid() { if !s.Valid() {
@ -91,6 +99,12 @@ func tagsForModelWithID(
res[stableIDKey] = string(id) res[stableIDKey] = string(id)
if _, ok := res[modelVersionKey]; ok {
return nil, errors.WithStack(errBadTagKey)
}
res[modelVersionKey] = strconv.Itoa(version)
return res, nil return res, nil
} }
@ -112,7 +126,7 @@ func putInner(
base.ID = model.StableID(uuid.NewString()) base.ID = model.StableID(uuid.NewString())
} }
tmpTags, err := tagsForModelWithID(s, base.ID, base.Tags) tmpTags, err := tagsForModelWithID(s, base.ID, base.Version, base.Tags)
if err != nil { if err != nil {
// Will be wrapped at a higher layer. // Will be wrapped at a higher layer.
return err return err
@ -140,6 +154,8 @@ func (ms *ModelStore) Put(
return errors.WithStack(errUnrecognizedSchema) return errors.WithStack(errUnrecognizedSchema)
} }
m.Base().Version = ms.modelVersion
err := repo.WriteSession( err := repo.WriteSession(
ctx, ctx,
ms.c, ms.c,
@ -159,22 +175,45 @@ func (ms *ModelStore) Put(
func stripHiddenTags(tags map[string]string) { func stripHiddenTags(tags map[string]string) {
delete(tags, stableIDKey) delete(tags, stableIDKey)
delete(tags, modelVersionKey)
delete(tags, manifest.TypeLabelKey) delete(tags, manifest.TypeLabelKey)
} }
func baseModelFromMetadata(m *manifest.EntryMetadata) (*model.BaseModel, error) { func (ms ModelStore) populateBaseModelFromMetadata(
base *model.BaseModel,
m *manifest.EntryMetadata,
) error {
id, ok := m.Labels[stableIDKey] id, ok := m.Labels[stableIDKey]
if !ok { if !ok {
return nil, errors.WithStack(errNoStableID) return errors.WithStack(errNoStableID)
} }
res := &model.BaseModel{ v, err := strconv.Atoi(m.Labels[modelVersionKey])
ModelStoreID: m.ID, if err != nil {
ID: model.StableID(id), return errors.Wrap(err, "parsing model version")
Tags: m.Labels,
} }
stripHiddenTags(res.Tags) if v != ms.modelVersion {
return errors.Errorf("bad model version %s", m.Labels[modelVersionKey])
}
base.ModelStoreID = m.ID
base.ID = model.StableID(id)
base.Version = v
base.Tags = m.Labels
stripHiddenTags(base.Tags)
return nil
}
func (ms ModelStore) baseModelFromMetadata(
m *manifest.EntryMetadata,
) (*model.BaseModel, error) {
res := &model.BaseModel{}
if err := ms.populateBaseModelFromMetadata(res, m); err != nil {
return nil, err
}
return res, nil return res, nil
} }
@ -208,7 +247,7 @@ func (ms *ModelStore) GetIDsForType(
res := make([]*model.BaseModel, 0, len(metadata)) res := make([]*model.BaseModel, 0, len(metadata))
for _, m := range metadata { for _, m := range metadata {
bm, err := baseModelFromMetadata(m) bm, err := ms.baseModelFromMetadata(m)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "parsing model metadata") return nil, errors.Wrap(err, "parsing model metadata")
} }
@ -304,12 +343,10 @@ func (ms *ModelStore) GetWithModelStoreID(
return errors.WithStack(errModelTypeMismatch) return errors.WithStack(errModelTypeMismatch)
} }
base := data.Base() return errors.Wrap(
base.Tags = metadata.Labels ms.populateBaseModelFromMetadata(data.Base(), metadata),
stripHiddenTags(base.Tags) "getting model by ID",
base.ModelStoreID = id )
return nil
} }
// checkPrevModelVersion compares the ModelType and ModelStoreID in this model // checkPrevModelVersion compares the ModelType and ModelStoreID in this model
@ -368,6 +405,8 @@ func (ms *ModelStore) Update(
return errors.WithStack(errNoModelStoreID) return errors.WithStack(errNoModelStoreID)
} }
base.Version = ms.modelVersion
// TODO(ashmrtnz): Can remove if bottleneck. // TODO(ashmrtnz): Can remove if bottleneck.
if err := ms.checkPrevModelVersion(ctx, s, base); err != nil { if err := ms.checkPrevModelVersion(ctx, s, base); err != nil {
return err return err

View File

@ -29,7 +29,7 @@ func getModelStore(t *testing.T, ctx context.Context) *ModelStore {
c, err := openKopiaRepo(t, ctx) c, err := openKopiaRepo(t, ctx)
require.NoError(t, err) require.NoError(t, err)
return &ModelStore{c} return &ModelStore{c: c, modelVersion: globalModelVersion}
} }
// --------------- // ---------------
@ -101,6 +101,12 @@ func (suite *ModelStoreIntegrationSuite) TestBadTagsErrors() {
manifest.TypeLabelKey: "foo", manifest.TypeLabelKey: "foo",
}, },
}, },
{
name: "storeVersion",
tags: map[string]string{
manifest.TypeLabelKey: "foo",
},
},
} }
for _, test := range table { for _, test := range table {
@ -204,6 +210,23 @@ func (suite *ModelStoreIntegrationSuite) TestBadTypeErrors() {
) )
} }
func (suite *ModelStoreIntegrationSuite) TestPutGetBadVersion() {
t := suite.T()
schema := model.BackupOpSchema
foo := &fooModel{Bar: uuid.NewString()}
// Avoid some silly test errors from comparing nil to empty map.
foo.Tags = map[string]string{}
err := suite.m.Put(suite.ctx, schema, foo)
require.NoError(t, err)
suite.m.modelVersion = 42
returned := &fooModel{}
err = suite.m.Get(suite.ctx, schema, foo.ID, returned)
assert.Error(t, err)
}
func (suite *ModelStoreIntegrationSuite) TestPutGet() { func (suite *ModelStoreIntegrationSuite) TestPutGet() {
table := []struct { table := []struct {
s model.Schema s model.Schema
@ -247,6 +270,7 @@ func (suite *ModelStoreIntegrationSuite) TestPutGet() {
require.NotEmpty(t, foo.ModelStoreID) require.NotEmpty(t, foo.ModelStoreID)
require.NotEmpty(t, foo.ID) require.NotEmpty(t, foo.ID)
require.Equal(t, globalModelVersion, foo.Version)
returned := &fooModel{} returned := &fooModel{}
err = suite.m.Get(suite.ctx, test.s, foo.ID, returned) err = suite.m.Get(suite.ctx, test.s, foo.ID, returned)
@ -340,6 +364,22 @@ func (suite *ModelStoreIntegrationSuite) TestGet_NotFoundErrors() {
t, suite.m.GetWithModelStoreID(suite.ctx, model.BackupOpSchema, "baz", nil), ErrNotFound) t, suite.m.GetWithModelStoreID(suite.ctx, model.BackupOpSchema, "baz", nil), ErrNotFound)
} }
func (suite *ModelStoreIntegrationSuite) TestPutGetOfTypeBadVersion() {
t := suite.T()
schema := model.BackupOpSchema
foo := &fooModel{Bar: uuid.NewString()}
err := suite.m.Put(suite.ctx, schema, foo)
require.NoError(t, err)
suite.m.modelVersion = 42
ids, err := suite.m.GetIDsForType(suite.ctx, schema, nil)
assert.Error(t, err)
assert.Empty(t, ids)
}
func (suite *ModelStoreIntegrationSuite) TestPutGetOfType() { func (suite *ModelStoreIntegrationSuite) TestPutGetOfType() {
table := []struct { table := []struct {
s model.Schema s model.Schema
@ -527,12 +567,14 @@ func (suite *ModelStoreIntegrationSuite) TestPutUpdate() {
name: "NoTags", name: "NoTags",
mutator: func(m *fooModel) { mutator: func(m *fooModel) {
m.Bar = "baz" m.Bar = "baz"
m.Version = 42
}, },
}, },
{ {
name: "WithTags", name: "WithTags",
mutator: func(m *fooModel) { mutator: func(m *fooModel) {
m.Bar = "baz" m.Bar = "baz"
m.Version = 42
m.Tags = map[string]string{ m.Tags = map[string]string{
"a": "42", "a": "42",
} }
@ -558,11 +600,15 @@ func (suite *ModelStoreIntegrationSuite) TestPutUpdate() {
oldModelID := foo.ModelStoreID oldModelID := foo.ModelStoreID
oldStableID := foo.ID oldStableID := foo.ID
oldVersion := foo.Version
test.mutator(foo) test.mutator(foo)
require.NoError(t, m.Update(ctx, theModelType, foo)) require.NoError(t, m.Update(ctx, theModelType, foo))
assert.Equal(t, oldStableID, foo.ID) assert.Equal(t, oldStableID, foo.ID)
// The version in the model store has not changed so we get the old
// version back.
assert.Equal(t, oldVersion, foo.Version)
returned := &fooModel{} returned := &fooModel{}
require.NoError( require.NoError(
@ -571,7 +617,8 @@ func (suite *ModelStoreIntegrationSuite) TestPutUpdate() {
ids, err := m.GetIDsForType(ctx, theModelType, nil) ids, err := m.GetIDsForType(ctx, theModelType, nil)
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, ids, 1) require.Len(t, ids, 1)
assert.Equal(t, globalModelVersion, ids[0].Version)
if oldModelID == foo.ModelStoreID { if oldModelID == foo.ModelStoreID {
// Unlikely, but we don't control ModelStoreID generation and can't // Unlikely, but we don't control ModelStoreID generation and can't

View File

@ -44,6 +44,9 @@ type BaseModel struct {
// to refer to this one. This field may change if the model is updated. This // to refer to this one. This field may change if the model is updated. This
// field should be treated as read-only by users. // field should be treated as read-only by users.
ModelStoreID manifest.ID `json:"-"` ModelStoreID manifest.ID `json:"-"`
// Version is a version number that can help track changes across models.
// TODO(ashmrtn): Reference version control documentation.
Version int `json:"-"`
// Tags associated with this model in the store to facilitate lookup. Tags in // Tags associated with this model in the store to facilitate lookup. Tags in
// the struct are not serialized directly into the stored model, but are part // the struct are not serialized directly into the stored model, but are part
// of the metadata for the model. // of the metadata for the model.