tweaks and fixes found while testing (#3735)

a variety of small updates that came
from manual testing of restore with various
collision and destination combinations.

---

#### Does this PR need a docs update or release note?

- [x]  No

#### Type of change

- [x] 🐛 Bugfix

#### Issue(s)

* #3562

#### Test Plan

- [x] 💪 Manual
This commit is contained in:
Keepers 2023-07-06 16:24:26 -06:00 committed by GitHub
parent 1255f06aed
commit 5ea194dc87
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 150 additions and 82 deletions

View File

@ -84,14 +84,17 @@ func runRestore(
return Only(ctx, clues.Wrap(err, "Failed to run "+serviceName+" restore")) return Only(ctx, clues.Wrap(err, "Failed to run "+serviceName+" restore"))
} }
Info(ctx, "Completed Restore:") Info(ctx, "Restore Complete")
skipped := ro.Counter.Get(count.CollisionSkip) skipped := ro.Counter.Get(count.CollisionSkip)
if skipped > 0 { if skipped > 0 {
Infof(ctx, "Skipped %d items due to collision", skipped) Infof(ctx, "Skipped %d items due to collision", skipped)
} }
ds.Items().MaybePrintEntries(ctx) dis := ds.Items()
Outf(ctx, "Restored %d items", len(dis))
dis.MaybePrintEntries(ctx)
return nil return nil
} }

View File

@ -60,9 +60,8 @@ func (h contactRestoreHandler) GetContainerByName(
return h.ac.GetContainerByName(ctx, userID, "", containerName) return h.ac.GetContainerByName(ctx, userID, "", containerName)
} }
// always returns the provided value func (h contactRestoreHandler) defaultRootContainer() string {
func (h contactRestoreHandler) orRootContainer(c string) string { return api.DefaultContacts
return c
} }
func (h contactRestoreHandler) restore( func (h contactRestoreHandler) restore(
@ -100,6 +99,14 @@ func restoreContact(
errs *fault.Bus, errs *fault.Bus,
ctr *count.Bus, ctr *count.Bus,
) (*details.ExchangeInfo, error) { ) (*details.ExchangeInfo, error) {
// contacts has a weird relationship with its default
// folder, which is that the folder is treated as invisible
// in many cases. If we're restoring to a blank location,
// we can interpret that as the root.
if len(destinationID) == 0 {
destinationID = api.DefaultContacts
}
contact, err := api.BytesToContactable(body) contact, err := api.BytesToContactable(body)
if err != nil { if err != nil {
return nil, graph.Wrap(ctx, err, "creating contact from bytes") return nil, graph.Wrap(ctx, err, "creating contact from bytes")
@ -139,7 +146,7 @@ func restoreContact(
// at least we'll have accidentally over-produced data instead of deleting // at least we'll have accidentally over-produced data instead of deleting
// the user's data. // the user's data.
if shouldDeleteOriginal { if shouldDeleteOriginal {
if err := cr.DeleteItem(ctx, userID, collisionID); err != nil { if err := cr.DeleteItem(ctx, userID, collisionID); err != nil && !graph.IsErrDeletedInFlight(err) {
return nil, graph.Wrap(ctx, err, "deleting colliding contact") return nil, graph.Wrap(ctx, err, "deleting colliding contact")
} }
} }

View File

@ -176,6 +176,19 @@ func (suite *ContactsRestoreIntgSuite) TestRestoreContact() {
assert.True(t, m.calledDelete, "old item deleted") assert.True(t, m.calledDelete, "old item deleted")
}, },
}, },
{
name: "collision: replace - err already deleted",
apiMock: &contactRestoreMock{deleteItemErr: graph.ErrDeletedInFlight},
collisionMap: map[string]string{collisionKey: "smarf"},
onCollision: control.Replace,
expectErr: func(t *testing.T, err error) {
assert.NoError(t, err, clues.ToCore(err))
},
expectMock: func(t *testing.T, m *contactRestoreMock) {
assert.True(t, m.calledPost, "new item posted")
assert.True(t, m.calledDelete, "old item deleted")
},
},
} }
for _, test := range table { for _, test := range table {
suite.Run(test.name, func() { suite.Run(test.name, func() {

View File

@ -806,6 +806,9 @@ func runCreateDestinationTest(
gcc = handler.newContainerCache(userID) gcc = handler.newContainerCache(userID)
) )
err := gcc.Populate(ctx, fault.New(true), handler.defaultRootContainer())
require.NoError(t, err, clues.ToCore(err))
path1, err := path.Build( path1, err := path.Build(
tenantID, tenantID,
userID, userID,
@ -821,7 +824,6 @@ func runCreateDestinationTest(
handler.formatRestoreDestination(destinationName, path1), handler.formatRestoreDestination(destinationName, path1),
userID, userID,
gcc, gcc,
true,
fault.New(true)) fault.New(true))
require.NoError(t, err, clues.ToCore(err)) require.NoError(t, err, clues.ToCore(err))
@ -843,7 +845,6 @@ func runCreateDestinationTest(
handler.formatRestoreDestination(destinationName, path2), handler.formatRestoreDestination(destinationName, path2),
userID, userID,
gcc, gcc,
false,
fault.New(true)) fault.New(true))
require.NoError(t, err, clues.ToCore(err)) require.NoError(t, err, clues.ToCore(err))

View File

@ -44,6 +44,10 @@ func (h eventRestoreHandler) formatRestoreDestination(
destinationContainerName string, destinationContainerName string,
_ path.Path, // ignored because calendars cannot be nested _ path.Path, // ignored because calendars cannot be nested
) *path.Builder { ) *path.Builder {
if len(destinationContainerName) == 0 {
destinationContainerName = api.DefaultCalendar
}
return path.Builder{}.Append(destinationContainerName) return path.Builder{}.Append(destinationContainerName)
} }
@ -62,8 +66,8 @@ func (h eventRestoreHandler) GetContainerByName(
} }
// always returns the provided value // always returns the provided value
func (h eventRestoreHandler) orRootContainer(c string) string { func (h eventRestoreHandler) defaultRootContainer() string {
return c return api.DefaultCalendar
} }
func (h eventRestoreHandler) restore( func (h eventRestoreHandler) restore(
@ -151,7 +155,7 @@ func restoreEvent(
// at least we'll have accidentally over-produced data instead of deleting // at least we'll have accidentally over-produced data instead of deleting
// the user's data. // the user's data.
if shouldDeleteOriginal { if shouldDeleteOriginal {
if err := er.DeleteItem(ctx, userID, collisionID); err != nil { if err := er.DeleteItem(ctx, userID, collisionID); err != nil && !graph.IsErrDeletedInFlight(err) {
return nil, graph.Wrap(ctx, err, "deleting colliding event") return nil, graph.Wrap(ctx, err, "deleting colliding event")
} }
} }

View File

@ -224,6 +224,19 @@ func (suite *EventsRestoreIntgSuite) TestRestoreEvent() {
assert.True(t, m.calledDelete, "old item deleted") assert.True(t, m.calledDelete, "old item deleted")
}, },
}, },
{
name: "collision: replace - err already deleted",
apiMock: &eventRestoreMock{deleteItemErr: graph.ErrDeletedInFlight},
collisionMap: map[string]string{collisionKey: "smarf"},
onCollision: control.Replace,
expectErr: func(t *testing.T, err error) {
assert.NoError(t, err, clues.ToCore(err))
},
expectMock: func(t *testing.T, m *eventRestoreMock) {
assert.True(t, m.calledPost, "new item posted")
assert.True(t, m.calledDelete, "old item deleted")
},
},
} }
for _, test := range table { for _, test := range table {
suite.Run(test.name, func() { suite.Run(test.name, func() {

View File

@ -95,12 +95,7 @@ type containerAPI interface {
ctx context.Context, ctx context.Context,
userID, parentContainerID, containerName string, userID, parentContainerID, containerName string,
) (graph.Container, error) ) (graph.Container, error)
defaultRootContainer() string
// returns either the provided value (assumed to be the root
// folder for that cache tree), or the default root container
// (if the category uses a root folder that exists above the
// restore location path).
orRootContainer(string) string
} }
type containerByNamer interface { type containerByNamer interface {

View File

@ -65,8 +65,7 @@ func (h mailRestoreHandler) GetContainerByName(
return h.ac.GetContainerByName(ctx, userID, parentContainerID, containerName) return h.ac.GetContainerByName(ctx, userID, parentContainerID, containerName)
} }
// always returns rootFolderAlias func (h mailRestoreHandler) defaultRootContainer() string {
func (h mailRestoreHandler) orRootContainer(string) string {
return api.MsgFolderRoot return api.MsgFolderRoot
} }
@ -151,7 +150,7 @@ func restoreMail(
// at least we'll have accidentally over-produced data instead of deleting // at least we'll have accidentally over-produced data instead of deleting
// the user's data. // the user's data.
if shouldDeleteOriginal { if shouldDeleteOriginal {
if err := mr.DeleteItem(ctx, userID, collisionID); err != nil { if err := mr.DeleteItem(ctx, userID, collisionID); err != nil && !graph.IsErrDeletedInFlight(err) {
return nil, graph.Wrap(ctx, err, "deleting colliding mail message") return nil, graph.Wrap(ctx, err, "deleting colliding mail message")
} }
} }

View File

@ -193,6 +193,19 @@ func (suite *MailRestoreIntgSuite) TestRestoreMail() {
assert.True(t, m.calledDelete, "old item deleted") assert.True(t, m.calledDelete, "old item deleted")
}, },
}, },
{
name: "collision: replace - err already deleted",
apiMock: &mailRestoreMock{deleteItemErr: graph.ErrDeletedInFlight},
collisionMap: map[string]string{collisionKey: "smarf"},
onCollision: control.Replace,
expectErr: func(t *testing.T, err error) {
assert.NoError(t, err, clues.ToCore(err))
},
expectMock: func(t *testing.T, m *mailRestoreMock) {
assert.True(t, m.calledPost, "new item posted")
assert.True(t, m.calledDelete, "old item deleted")
},
},
} }
for _, test := range table { for _, test := range table {
suite.Run(test.name, func() { suite.Run(test.name, func() {

View File

@ -55,7 +55,6 @@ func ConsumeRestoreCollections(
} }
var ( var (
isNewCache bool
category = dc.FullPath().Category() category = dc.FullPath().Category()
ictx = clues.Add( ictx = clues.Add(
ctx, ctx,
@ -70,8 +69,12 @@ func ConsumeRestoreCollections(
} }
if directoryCache[category] == nil { if directoryCache[category] == nil {
directoryCache[category] = handler.newContainerCache(userID) gcr := handler.newContainerCache(userID)
isNewCache = true if err := gcr.Populate(ctx, errs, handler.defaultRootContainer()); err != nil {
return nil, clues.Wrap(err, "populating container cache")
}
directoryCache[category] = gcr
} }
containerID, gcc, err := createDestination( containerID, gcc, err := createDestination(
@ -80,7 +83,6 @@ func ConsumeRestoreCollections(
handler.formatRestoreDestination(restoreCfg.Location, dc.FullPath()), handler.formatRestoreDestination(restoreCfg.Location, dc.FullPath()),
userID, userID,
directoryCache[category], directoryCache[category],
isNewCache,
errs) errs)
if err != nil { if err != nil {
el.AddRecoverable(ctx, err) el.AddRecoverable(ctx, err)
@ -240,7 +242,6 @@ func createDestination(
destination *path.Builder, destination *path.Builder,
userID string, userID string,
gcr graph.ContainerResolver, gcr graph.ContainerResolver,
isNewCache bool,
errs *fault.Bus, errs *fault.Bus,
) (string, graph.ContainerResolver, error) { ) (string, graph.ContainerResolver, error) {
var ( var (
@ -254,12 +255,11 @@ func createDestination(
ictx := clues.Add( ictx := clues.Add(
ctx, ctx,
"is_new_cache", isNewCache,
"container_parent_id", containerParentID, "container_parent_id", containerParentID,
"container_name", container, "container_name", container,
"restore_location", restoreLoc) "restore_location", restoreLoc)
fid, err := getOrPopulateContainer( containerID, err := getOrPopulateContainer(
ictx, ictx,
ca, ca,
cache, cache,
@ -267,13 +267,12 @@ func createDestination(
userID, userID,
containerParentID, containerParentID,
container, container,
isNewCache,
errs) errs)
if err != nil { if err != nil {
return "", cache, clues.Stack(err) return "", cache, clues.Stack(err)
} }
containerParentID = fid containerParentID = containerID
} }
// containerParentID now identifies the last created container, // containerParentID now identifies the last created container,
@ -287,7 +286,6 @@ func getOrPopulateContainer(
gcr graph.ContainerResolver, gcr graph.ContainerResolver,
restoreLoc *path.Builder, restoreLoc *path.Builder,
userID, containerParentID, containerName string, userID, containerParentID, containerName string,
isNewCache bool,
errs *fault.Bus, errs *fault.Bus,
) (string, error) { ) (string, error) {
cached, ok := gcr.LocationInCache(restoreLoc.String()) cached, ok := gcr.LocationInCache(restoreLoc.String())
@ -318,12 +316,6 @@ func getOrPopulateContainer(
folderID := ptr.Val(c.GetId()) folderID := ptr.Val(c.GetId())
if isNewCache {
if err := gcr.Populate(ctx, errs, folderID, ca.orRootContainer(restoreLoc.HeadElem())); err != nil {
return "", clues.Wrap(err, "populating container cache")
}
}
if err = gcr.AddToCache(ctx, c); err != nil { if err = gcr.AddToCache(ctx, c); err != nil {
return "", clues.Wrap(err, "adding container to cache") return "", clues.Wrap(err, "adding container to cache")
} }

View File

@ -143,12 +143,11 @@ func (mw *LoggingMiddleware) Intercept(
// special cases where we always dump the response body, since the response // special cases where we always dump the response body, since the response
// details might be critical to understanding the response when debugging. // details might be critical to understanding the response when debugging.
// * 400-bad-request
// * 403-forbidden
logBody = logger.DebugAPIFV || logBody = logger.DebugAPIFV ||
os.Getenv(logGraphRequestsEnvKey) != "" || os.Getenv(logGraphRequestsEnvKey) != "" ||
resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusBadRequest ||
resp.StatusCode == http.StatusForbidden resp.StatusCode == http.StatusForbidden ||
resp.StatusCode == http.StatusConflict
) )
// special case: always info-level status 429 logs // special case: always info-level status 429 logs

View File

@ -827,7 +827,7 @@ func restoreFile(
item = newItem(name, false) item = newItem(name, false)
collisionKey = api.DriveItemCollisionKey(item) collisionKey = api.DriveItemCollisionKey(item)
collision api.DriveCollisionItem collision api.DriveCollisionItem
replace bool shouldDeleteOriginal bool
) )
if dci, ok := collisionKeyToItemID[collisionKey]; ok { if dci, ok := collisionKeyToItemID[collisionKey]; ok {
@ -842,7 +842,7 @@ func restoreFile(
} }
collision = dci collision = dci
replace = restoreCfg.OnCollision == control.Replace && !dci.IsFolder shouldDeleteOriginal = restoreCfg.OnCollision == control.Replace && !dci.IsFolder
} }
// drive items do not support PUT requests on the drive item data, so // drive items do not support PUT requests on the drive item data, so
@ -852,8 +852,8 @@ func restoreFile(
// conflict replace handling bug gets fixed, we either delete-post, and // conflict replace handling bug gets fixed, we either delete-post, and
// risk failures in the middle, or we post w/ copy, then delete, then patch // risk failures in the middle, or we post w/ copy, then delete, then patch
// the name, which could triple our graph calls in the worst case. // the name, which could triple our graph calls in the worst case.
if replace { if shouldDeleteOriginal {
if err := ir.DeleteItem(ctx, driveID, collision.ItemID); err != nil { if err := ir.DeleteItem(ctx, driveID, collision.ItemID); err != nil && !graph.IsErrDeletedInFlight(err) {
return "", details.ItemInfo{}, clues.New("deleting colliding item") return "", details.ItemInfo{}, clues.New("deleting colliding item")
} }
} }

View File

@ -333,6 +333,7 @@ func (suite *RestoreUnitSuite) TestRestoreItem_collisionHandling() {
name string name string
collisionKeys map[string]api.DriveCollisionItem collisionKeys map[string]api.DriveCollisionItem
onCollision control.CollisionPolicy onCollision control.CollisionPolicy
deleteErr error
expectSkipped assert.BoolAssertionFunc expectSkipped assert.BoolAssertionFunc
expectMock func(*testing.T, *mock.RestoreHandler) expectMock func(*testing.T, *mock.RestoreHandler)
}{ }{
@ -391,6 +392,19 @@ func (suite *RestoreUnitSuite) TestRestoreItem_collisionHandling() {
assert.Equal(t, mndiID, rh.CalledDeleteItemOn, "deleted the correct item") assert.Equal(t, mndiID, rh.CalledDeleteItemOn, "deleted the correct item")
}, },
}, },
{
name: "collision, replace - err already deleted",
collisionKeys: map[string]api.DriveCollisionItem{
mock.DriveItemFileName: {ItemID: "smarf"},
},
onCollision: control.Replace,
deleteErr: graph.ErrDeletedInFlight,
expectSkipped: assert.False,
expectMock: func(t *testing.T, rh *mock.RestoreHandler) {
assert.True(t, rh.CalledPostItem, "new item posted")
assert.True(t, rh.CalledDeleteItem, "new item deleted")
},
},
{ {
name: "collision, skip", name: "collision, skip",
collisionKeys: map[string]api.DriveCollisionItem{ collisionKeys: map[string]api.DriveCollisionItem{
@ -461,7 +475,10 @@ func (suite *RestoreUnitSuite) TestRestoreItem_collisionHandling() {
var ( var (
caches = NewRestoreCaches() caches = NewRestoreCaches()
rh = &mock.RestoreHandler{PostItemResp: mndi} rh = &mock.RestoreHandler{
PostItemResp: models.NewDriveItem(),
DeleteItemErr: test.deleteErr,
}
restoreCfg = control.RestoreConfig{OnCollision: test.onCollision} restoreCfg = control.RestoreConfig{OnCollision: test.onCollision}
dpb = odConsts.DriveFolderPrefixBuilder("driveID1") dpb = odConsts.DriveFolderPrefixBuilder("driveID1")
) )

View File

@ -271,7 +271,7 @@ func (op *RestoreOperation) do(
op.Errors, op.Errors,
op.Counter) op.Counter)
if err != nil { if err != nil {
return nil, clues.Wrap(err, "restoring collections") return nil, clues.Stack(err)
} }
opStats.ctrl = op.rc.Wait() opStats.ctrl = op.rc.Wait()

View File

@ -22,7 +22,7 @@ import (
// Max number of items for which we will print details. If there are // Max number of items for which we will print details. If there are
// more than this, then we just show a summary. // more than this, then we just show a summary.
const maxPrintLimit = 15 const maxPrintLimit = 50
// LocationIDer provides access to location information but guarantees that it // LocationIDer provides access to location information but guarantees that it
// can also generate a unique location (among items in the same service but // can also generate a unique location (among items in the same service but
@ -504,13 +504,9 @@ func (ents entrySet) PrintEntries(ctx context.Context) {
// MaybePrintEntries is same as PrintEntries, but only prints if we // MaybePrintEntries is same as PrintEntries, but only prints if we
// have less than 15 items or is not json output. // have less than 15 items or is not json output.
func (ents entrySet) MaybePrintEntries(ctx context.Context) { func (ents entrySet) MaybePrintEntries(ctx context.Context) {
if len(ents) > maxPrintLimit && if len(ents) <= maxPrintLimit ||
!print.DisplayJSONFormat() && print.DisplayJSONFormat() ||
!print.DisplayVerbose() { print.DisplayVerbose() {
// TODO: Should we detect if the user is piping the output and
// print if that is the case?
print.Outf(ctx, "Restored %d items.", len(ents))
} else {
printEntries(ctx, ents) printEntries(ctx, ents)
} }
} }

View File

@ -17,16 +17,18 @@ const (
// get easily misspelled. // get easily misspelled.
// eg: we don't need a const for "id" // eg: we don't need a const for "id"
const ( const (
attendees = "attendees"
bccRecipients = "bccRecipients" bccRecipients = "bccRecipients"
ccRecipients = "ccRecipients" ccRecipients = "ccRecipients"
createdDateTime = "createdDateTime" createdDateTime = "createdDateTime"
displayName = "displayName" displayName = "displayName"
emailAddresses = "emailAddresses" emailAddresses = "emailAddresses"
givenName = "givenName" givenName = "givenName"
isCancelled = "isCancelled"
isDraft = "isDraft"
mobilePhone = "mobilePhone" mobilePhone = "mobilePhone"
parentFolderID = "parentFolderId" parentFolderID = "parentFolderId"
receivedDateTime = "receivedDateTime" receivedDateTime = "receivedDateTime"
recurrence = "recurrence"
sentDateTime = "sentDateTime" sentDateTime = "sentDateTime"
surname = "surname" surname = "surname"
toRecipients = "toRecipients" toRecipients = "toRecipients"

View File

@ -112,6 +112,12 @@ func (c Contacts) NewContactsPager(
options.QueryParameters.Select = selectProps options.QueryParameters.Select = selectProps
} }
// if we have no container ID, we need to fetch the
// base contacts container ID.
if len(containerID) == 0 {
containerID = DefaultContacts
}
builder := c.Stable. builder := c.Stable.
Client(). Client().
Users(). Users().

View File

@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"strconv"
"strings" "strings"
"time" "time"
@ -701,7 +702,17 @@ func EventFromMap(ev map[string]any) (models.Eventable, error) {
} }
func eventCollisionKeyProps() []string { func eventCollisionKeyProps() []string {
return idAnd("subject", "type", "start", "end", "attendees", "recurrence") // Do not use attendees here. We slice out attendees from the
// restored event so that they do not receive an email for every
// restoration item. Attendees will guarantee non-overlapping keys.
return idAnd(
"subject",
"type",
"start",
"end",
recurrence,
isCancelled,
isDraft)
} }
// EventCollisionKey constructs a key from the eventable's creation time, subject, and organizer. // EventCollisionKey constructs a key from the eventable's creation time, subject, and organizer.
@ -713,37 +724,34 @@ func EventCollisionKey(item models.Eventable) string {
var ( var (
subject = ptr.Val(item.GetSubject()) subject = ptr.Val(item.GetSubject())
attendees = item.GetAttendees() oftype = ptr.Val(item.GetType()).String()
a string startTime = item.GetStart()
oftype = ptr.Val(item.GetType()) start string
t = oftype.String() endTime = item.GetEnd()
start = item.GetStart() end string
s string
end = item.GetEnd()
e string
recurs = item.GetRecurrence() recurs = item.GetRecurrence()
r string recur string
cancelled = ptr.Val(item.GetIsCancelled())
draft = ptr.Val(item.GetIsDraft())
) )
for _, att := range attendees { if startTime != nil {
if att.GetEmailAddress() != nil { start = ptr.Val(startTime.GetDateTime())
a += ptr.Val(att.GetEmailAddress().GetAddress())
}
} }
if start != nil { if endTime != nil {
s = ptr.Val(start.GetDateTime()) end = ptr.Val(endTime.GetDateTime())
}
if end != nil {
e = ptr.Val(end.GetDateTime())
} }
if recurs != nil && recurs.GetPattern() != nil { if recurs != nil && recurs.GetPattern() != nil {
r = ptr.Val(recurs.GetPattern().GetOdataType()) recur = ptr.Val(recurs.GetPattern().GetOdataType())
} }
// this result gets hashed to ensure that an enormous list of attendees // this result gets hashed to ensure that an enormous list of attendees
// doesn't generate a multi-kb collision key. // doesn't generate a multi-kb collision key.
return clues.ConcealWith(clues.SHA256, subject+a+t+s+e+r) return subject +
oftype +
start + end + recur +
strconv.FormatBool(draft) +
strconv.FormatBool(cancelled)
} }