Handle subfolder moves and parent deletions right (#3186)
Properly merge items if a subfolder is moved, the original parent is deleted and recreated, and the subfolder is moved back to where it started See linked issue for a more detailed example Manually tested original issue and fix on OneDrive backup --- #### Does this PR need a docs update or release note? - [x] ✅ Yes, it's included - [ ] 🕐 Yes, but in a later PR - [ ] ⛔ No #### Type of change - [ ] 🌻 Feature - [x] 🐛 Bugfix - [ ] 🗺️ Documentation - [ ] 🤖 Supportability/Tests - [ ] 💻 CI/Deployment - [ ] 🧹 Tech Debt/Cleanup #### Issue(s) * closes #3185 #### Test Plan - [x] 💪 Manual - [x] ⚡ Unit test - [ ] 💚 E2E
This commit is contained in:
parent
897c0f8a07
commit
9a8ec099cb
@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- ParentPath of json output for Exchange calendar now shows names instead of IDs.
|
- ParentPath of json output for Exchange calendar now shows names instead of IDs.
|
||||||
- Fixed failure when downloading huge amount of attachments
|
- Fixed failure when downloading huge amount of attachments
|
||||||
- Graph API requests that return an ECONNRESET error are now retried.
|
- Graph API requests that return an ECONNRESET error are now retried.
|
||||||
|
- Fixed edge case in incremental backups where moving a subfolder, deleting and recreating the subfolder's original parent folder, and moving the subfolder back to where it started would skip backing up unchanged items in the subfolder.
|
||||||
|
|
||||||
### Known Issues
|
### Known Issues
|
||||||
- Restoring a OneDrive or SharePoint file with the same name as a file with that name as its M365 ID may restore both items.
|
- Restoring a OneDrive or SharePoint file with the same name as a file with that name as its M365 ID may restore both items.
|
||||||
|
|||||||
@ -727,7 +727,7 @@ func inflateCollectionTree(
|
|||||||
toMerge *mergeDetails,
|
toMerge *mergeDetails,
|
||||||
) (map[string]*treeMap, map[string]path.Path, error) {
|
) (map[string]*treeMap, map[string]path.Path, error) {
|
||||||
roots := make(map[string]*treeMap)
|
roots := make(map[string]*treeMap)
|
||||||
// Contains the old path for collections that have been moved or renamed.
|
// Contains the old path for collections that are not new.
|
||||||
// Allows resolving what the new path should be when walking the base
|
// Allows resolving what the new path should be when walking the base
|
||||||
// snapshot(s)'s hierarchy. Nil represents a collection that was deleted.
|
// snapshot(s)'s hierarchy. Nil represents a collection that was deleted.
|
||||||
updatedPaths := make(map[string]path.Path)
|
updatedPaths := make(map[string]path.Path)
|
||||||
@ -776,6 +776,14 @@ func inflateCollectionTree(
|
|||||||
if err := addMergeLocation(s, toMerge); err != nil {
|
if err := addMergeLocation(s, toMerge); err != nil {
|
||||||
return nil, nil, clues.Wrap(err, "adding merge location").WithClues(ictx)
|
return nil, nil, clues.Wrap(err, "adding merge location").WithClues(ictx)
|
||||||
}
|
}
|
||||||
|
case data.NotMovedState:
|
||||||
|
p := s.PreviousPath().String()
|
||||||
|
if _, ok := updatedPaths[p]; ok {
|
||||||
|
return nil, nil, clues.New("multiple previous state changes to collection").
|
||||||
|
WithClues(ictx)
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedPaths[p] = s.FullPath()
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.FullPath() == nil || len(s.FullPath().Elements()) == 0 {
|
if s.FullPath() == nil || len(s.FullPath().Elements()) == 0 {
|
||||||
|
|||||||
@ -1102,6 +1102,7 @@ func (suite *HierarchyBuilderUnitSuite) TestBuildDirectoryTreeSingleSubtree() {
|
|||||||
name: "AddsNewItems",
|
name: "AddsNewItems",
|
||||||
inputCollections: func() []data.BackupCollection {
|
inputCollections: func() []data.BackupCollection {
|
||||||
mc := exchMock.NewCollection(storePath, locPath, 1)
|
mc := exchMock.NewCollection(storePath, locPath, 1)
|
||||||
|
mc.PrevPath = storePath
|
||||||
mc.Names[0] = testFileName2
|
mc.Names[0] = testFileName2
|
||||||
mc.Data[0] = testFileData2
|
mc.Data[0] = testFileData2
|
||||||
mc.ColState = data.NotMovedState
|
mc.ColState = data.NotMovedState
|
||||||
@ -1137,6 +1138,7 @@ func (suite *HierarchyBuilderUnitSuite) TestBuildDirectoryTreeSingleSubtree() {
|
|||||||
name: "SkipsUpdatedItems",
|
name: "SkipsUpdatedItems",
|
||||||
inputCollections: func() []data.BackupCollection {
|
inputCollections: func() []data.BackupCollection {
|
||||||
mc := exchMock.NewCollection(storePath, locPath, 1)
|
mc := exchMock.NewCollection(storePath, locPath, 1)
|
||||||
|
mc.PrevPath = storePath
|
||||||
mc.Names[0] = testFileName
|
mc.Names[0] = testFileName
|
||||||
mc.Data[0] = testFileData2
|
mc.Data[0] = testFileData2
|
||||||
mc.ColState = data.NotMovedState
|
mc.ColState = data.NotMovedState
|
||||||
@ -2054,6 +2056,150 @@ func (suite *HierarchyBuilderUnitSuite) TestBuildDirectoryTreeMultipleSubdirecto
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// This could happen if a subfolder is moved out of the parent, the parent
|
||||||
|
// is deleted, a new folder at the same location as the parent is created,
|
||||||
|
// and then the subfolder is moved back to the same location.
|
||||||
|
name: "Delete Parent But Child Marked Not Moved Explicit New Parent",
|
||||||
|
inputCollections: func(t *testing.T) []data.BackupCollection {
|
||||||
|
inbox := exchMock.NewCollection(nil, inboxLocPath, 0)
|
||||||
|
inbox.PrevPath = inboxStorePath
|
||||||
|
inbox.ColState = data.DeletedState
|
||||||
|
|
||||||
|
inbox2 := exchMock.NewCollection(inboxStorePath, inboxLocPath, 1)
|
||||||
|
inbox2.PrevPath = nil
|
||||||
|
inbox2.ColState = data.NewState
|
||||||
|
inbox2.Names[0] = workFileName1
|
||||||
|
|
||||||
|
personal := exchMock.NewCollection(personalStorePath, personalLocPath, 0)
|
||||||
|
personal.PrevPath = personalStorePath
|
||||||
|
personal.ColState = data.NotMovedState
|
||||||
|
|
||||||
|
return []data.BackupCollection{inbox, inbox2, personal}
|
||||||
|
},
|
||||||
|
expected: expectedTreeWithChildren(
|
||||||
|
[]string{
|
||||||
|
testTenant,
|
||||||
|
service,
|
||||||
|
testUser,
|
||||||
|
category,
|
||||||
|
},
|
||||||
|
[]*expectedNode{
|
||||||
|
{
|
||||||
|
name: testInboxID,
|
||||||
|
children: []*expectedNode{
|
||||||
|
{
|
||||||
|
name: workFileName1,
|
||||||
|
children: []*expectedNode{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: personalID,
|
||||||
|
children: []*expectedNode{
|
||||||
|
{
|
||||||
|
name: personalFileName1,
|
||||||
|
children: []*expectedNode{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: personalFileName2,
|
||||||
|
children: []*expectedNode{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// This could happen if a subfolder is moved out of the parent, the parent
|
||||||
|
// is deleted, a new folder at the same location as the parent is created,
|
||||||
|
// and then the subfolder is moved back to the same location.
|
||||||
|
name: "Delete Parent But Child Marked Not Moved Implicit New Parent",
|
||||||
|
inputCollections: func(t *testing.T) []data.BackupCollection {
|
||||||
|
inbox := exchMock.NewCollection(nil, inboxLocPath, 0)
|
||||||
|
inbox.PrevPath = inboxStorePath
|
||||||
|
inbox.ColState = data.DeletedState
|
||||||
|
|
||||||
|
// New folder not explicitly listed as it may not have had new items.
|
||||||
|
personal := exchMock.NewCollection(personalStorePath, personalLocPath, 0)
|
||||||
|
personal.PrevPath = personalStorePath
|
||||||
|
personal.ColState = data.NotMovedState
|
||||||
|
|
||||||
|
return []data.BackupCollection{inbox, personal}
|
||||||
|
},
|
||||||
|
expected: expectedTreeWithChildren(
|
||||||
|
[]string{
|
||||||
|
testTenant,
|
||||||
|
service,
|
||||||
|
testUser,
|
||||||
|
category,
|
||||||
|
},
|
||||||
|
[]*expectedNode{
|
||||||
|
{
|
||||||
|
name: testInboxID,
|
||||||
|
children: []*expectedNode{
|
||||||
|
{
|
||||||
|
name: personalID,
|
||||||
|
children: []*expectedNode{
|
||||||
|
{
|
||||||
|
name: personalFileName1,
|
||||||
|
children: []*expectedNode{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: personalFileName2,
|
||||||
|
children: []*expectedNode{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// This could happen if a subfolder is moved out of the parent, the parent
|
||||||
|
// is deleted, a new folder at the same location as the parent is created,
|
||||||
|
// and then the subfolder is moved back to the same location.
|
||||||
|
name: "Delete Parent But Child Marked Not Moved Implicit New Parent Child Do Not Merge",
|
||||||
|
inputCollections: func(t *testing.T) []data.BackupCollection {
|
||||||
|
inbox := exchMock.NewCollection(nil, inboxLocPath, 0)
|
||||||
|
inbox.PrevPath = inboxStorePath
|
||||||
|
inbox.ColState = data.DeletedState
|
||||||
|
|
||||||
|
// New folder not explicitly listed as it may not have had new items.
|
||||||
|
personal := exchMock.NewCollection(personalStorePath, personalLocPath, 1)
|
||||||
|
personal.PrevPath = personalStorePath
|
||||||
|
personal.ColState = data.NotMovedState
|
||||||
|
personal.DoNotMerge = true
|
||||||
|
personal.Names[0] = workFileName1
|
||||||
|
|
||||||
|
return []data.BackupCollection{inbox, personal}
|
||||||
|
},
|
||||||
|
expected: expectedTreeWithChildren(
|
||||||
|
[]string{
|
||||||
|
testTenant,
|
||||||
|
service,
|
||||||
|
testUser,
|
||||||
|
category,
|
||||||
|
},
|
||||||
|
[]*expectedNode{
|
||||||
|
{
|
||||||
|
name: testInboxID,
|
||||||
|
children: []*expectedNode{
|
||||||
|
{
|
||||||
|
name: personalID,
|
||||||
|
children: []*expectedNode{
|
||||||
|
{
|
||||||
|
name: workFileName1,
|
||||||
|
children: []*expectedNode{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range table {
|
for _, test := range table {
|
||||||
|
|||||||
@ -620,7 +620,7 @@ func (c mockBackupCollection) FullPath() path.Path {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c mockBackupCollection) PreviousPath() path.Path {
|
func (c mockBackupCollection) PreviousPath() path.Path {
|
||||||
return nil
|
return c.path
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c mockBackupCollection) LocationPath() *path.Builder {
|
func (c mockBackupCollection) LocationPath() *path.Builder {
|
||||||
@ -1034,6 +1034,7 @@ func (suite *KopiaSimpleRepoIntegrationSuite) TestBackupExcludeItem() {
|
|||||||
suite.testPath1,
|
suite.testPath1,
|
||||||
1)
|
1)
|
||||||
c.ColState = data.NotMovedState
|
c.ColState = data.NotMovedState
|
||||||
|
c.PrevPath = suite.testPath1
|
||||||
|
|
||||||
return []data.BackupCollection{c}
|
return []data.BackupCollection{c}
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user