add basic folder operations to tree (#4696)

adds SetFolder and AddTombstone operations
to the drive delta tree.

---

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

- [x]  No

#### Type of change

- [x] 🌻 Feature

#### Issue(s)

* #4689

#### Test Plan

- [x]  Unit test
This commit is contained in:
Keepers 2023-11-28 10:20:22 -07:00 committed by GitHub
parent d36636285a
commit bbf5350f6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 831 additions and 16 deletions

View File

@ -1,8 +1,13 @@
package drive
import (
"context"
"time"
"github.com/alcionai/clues"
odConsts "github.com/alcionai/corso/src/internal/m365/service/onedrive/consts"
"github.com/alcionai/corso/src/pkg/logger"
"github.com/alcionai/corso/src/pkg/path"
)
@ -15,8 +20,8 @@ type folderyMcFolderFace struct {
prefix path.Path
// the root of the tree;
// new, moved, and notMoved collections
collections *nodeyMcNodeFace
// new, moved, and notMoved root
root *nodeyMcNodeFace
// the majority of operations we perform can be handled with
// a folder ID lookup instead of re-walking the entire tree.
@ -24,8 +29,9 @@ type folderyMcFolderFace struct {
folderIDToNode map[string]*nodeyMcNodeFace
// tombstones don't need to form a tree.
// we only need the folder ID and their previous path.
tombstones map[string]path.Path
// We maintain the node data in case we swap back
// and forth between live and tombstoned states.
tombstones map[string]*nodeyMcNodeFace
// it's just a sensible place to store the data, since we're
// already pushing file additions through the api.
@ -38,7 +44,7 @@ func newFolderyMcFolderFace(
return &folderyMcFolderFace{
prefix: prefix,
folderIDToNode: map[string]*nodeyMcNodeFace{},
tombstones: map[string]path.Path{},
tombstones: map[string]*nodeyMcNodeFace{},
excludeFileIDs: map[string]struct{}{},
}
}
@ -53,9 +59,9 @@ type nodeyMcNodeFace struct {
// single directory name, not a path
name string
// only contains the folders starting at and including '/root:'
prev path.Path
prev path.Elements
// map folderID -> node
childDirs map[string]*nodeyMcNodeFace
children map[string]*nodeyMcNodeFace
// items are keyed by item ID
items map[string]time.Time
// for special handling protocols around packages
@ -65,7 +71,7 @@ type nodeyMcNodeFace struct {
func newNodeyMcNodeFace(
parent *nodeyMcNodeFace,
id, name string,
prev path.Path,
prev path.Elements,
isPackage bool,
) *nodeyMcNodeFace {
return &nodeyMcNodeFace{
@ -73,8 +79,176 @@ func newNodeyMcNodeFace(
id: id,
name: name,
prev: prev,
childDirs: map[string]*nodeyMcNodeFace{},
children: map[string]*nodeyMcNodeFace{},
items: map[string]time.Time{},
isPackage: isPackage,
}
}
// ---------------------------------------------------------------------------
// folder handling
// ---------------------------------------------------------------------------
// SetFolder adds a node with the following details to the tree.
// If the node already exists with the given ID, the name and parent
// values are updated to match (isPackage is assumed not to change).
func (face *folderyMcFolderFace) SetFolder(
ctx context.Context,
parentID, id, name string,
isPackage bool,
) error {
// need to ensure we have the minimum requirements met for adding a node.
if len(id) == 0 {
return clues.NewWC(ctx, "missing folder ID")
}
if len(name) == 0 {
return clues.NewWC(ctx, "missing folder name")
}
// drive doesn't normally allow the `:` character in folder names.
// so `root:` is, by default, the only folder that can match this
// name. That makes this check a little bit brittle, but generally
// reliable, since we should always see the root first and can rely
// on the naming structure.
if len(parentID) == 0 && name != odConsts.RootPathDir {
return clues.NewWC(ctx, "non-root folder missing parent id")
}
// only set the root node once.
if name == odConsts.RootPathDir {
if face.root == nil {
root := newNodeyMcNodeFace(nil, id, name, nil, isPackage)
face.root = root
face.folderIDToNode[id] = root
}
return nil
}
// There are four possible changes that can happen at this point.
// 1. new folder addition.
// 2. duplicate folder addition.
// 3. existing folder migrated to new location.
// 4. tombstoned folder restored.
parent, ok := face.folderIDToNode[parentID]
if !ok {
return clues.NewWC(ctx, "folder added before parent")
}
// Handling case 4 is exclusive to 1-3. IE: we ensure tree state such
// that a node's previous appearance can be either a tombstone or
// a live node, but not both. So if we find a tombstone, we assume
// there is not also a node in the live tree for this id.
// if a folder is deleted and restored, we'll get both the deletion marker
// (which should be first in enumeration, since all deletion markers are first,
// or it would have happened in one of the prior enumerations), followed by
// the restoration of the folder.
if zombey, tombstoned := face.tombstones[id]; tombstoned {
delete(face.tombstones, id)
zombey.parent = parent
zombey.name = name
parent.children[id] = zombey
face.folderIDToNode[id] = zombey
return nil
}
// if not previously a tombstone, handle change cases 1-3
var (
nodey *nodeyMcNodeFace
visited bool
)
// change type 2 & 3. Update the existing node details to match current data.
if nodey, visited = face.folderIDToNode[id]; visited {
if nodey.parent == nil {
// technically shouldn't be possible but better to keep the problem tracked
// just in case.
logger.Ctx(ctx).Info("non-root folder already exists with no parent ref")
} else if nodey.parent != parent {
// change type 3. we need to ensure the old parent stops pointing to this node.
delete(nodey.parent.children, id)
}
nodey.name = name
nodey.parent = parent
} else {
// change type 1: new addition
// the previous location is always nil, since previous path additions get their
// own setter func.
nodey = newNodeyMcNodeFace(parent, id, name, nil, isPackage)
}
// ensure the parent points to this node, and that the node is registered
// in the map of all nodes in the tree.
parent.children[id] = nodey
face.folderIDToNode[id] = nodey
return nil
}
func (face *folderyMcFolderFace) SetTombstone(
ctx context.Context,
id string,
loc path.Elements,
) error {
if len(id) == 0 {
return clues.NewWC(ctx, "missing tombstone folder ID")
}
if len(loc) == 0 {
return clues.NewWC(ctx, "missing tombstone location")
}
// since we run mutiple enumerations, it's possible to see a folder added on the
// first enumeration that then gets deleted on the next. This means that the folder
// was deleted while the first enumeration was in flight, and will show up even if
// the folder gets restored after deletion.
// When this happens, we have to adjust the original tree accordingly.
if zombey, stillKicking := face.folderIDToNode[id]; stillKicking {
if zombey.parent != nil {
delete(zombey.parent.children, id)
}
delete(face.folderIDToNode, id)
zombey.parent = nil
face.tombstones[id] = zombey
// this handling is exclusive to updating an already-existing tombstone.
// ie: if we find a living node in the tree, we assume there is no tombstone
// entry with the same ID.
return nil
}
zombey, alreadyBuried := face.tombstones[id]
if alreadyBuried {
if zombey.prev.String() != loc.String() {
// logging for sanity
logger.Ctx(ctx).Infow(
"attempted to tombstone two paths with the same ID",
"first_tombstone_path", zombey.prev,
"second_tombstone_path", loc)
}
// since we're storing drive data by folder name in kopia, not id, we need
// to make sure to preserve the original tombstone location. If we get a
// conflicting set of locations in the same delta enumeration, we can always
// treat the original one as the canonical one. IE: what we're deleting is
// the original location as it exists in kopia. So even if we get a newer
// location in the drive enumeration, the original location is the one that
// kopia uses, and the one we need to tombstone.
//
// this should also be asserted in the second step, where we compare the delta
// changes to the backup previous paths metadata.
face.tombstones[id] = zombey
} else {
face.tombstones[id] = newNodeyMcNodeFace(nil, id, "", loc, false)
}
return nil
}

View File

@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
odConsts "github.com/alcionai/corso/src/internal/m365/service/onedrive/consts"
"github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/pkg/path"
)
@ -30,7 +31,7 @@ func (suite *DeltaTreeUnitSuite) TestNewFolderyMcFolderFace() {
folderFace := newFolderyMcFolderFace(p)
assert.Equal(t, p, folderFace.prefix)
assert.Nil(t, folderFace.collections)
assert.Nil(t, folderFace.root)
assert.NotNil(t, folderFace.folderIDToNode)
assert.NotNil(t, folderFace.tombstones)
assert.NotNil(t, folderFace.excludeFileIDs)
@ -40,17 +41,657 @@ func (suite *DeltaTreeUnitSuite) TestNewNodeyMcNodeFace() {
var (
t = suite.T()
parent = &nodeyMcNodeFace{}
p, err = path.Build("t", "r", path.SharePointService, path.LibrariesCategory, false, "drive-id", "root:")
loc = path.NewElements("root:/foo/bar/baz/qux/fnords/smarf/voi/zumba/bangles/howdyhowdyhowdy")
)
require.NoError(t, err, clues.ToCore(err))
nodeFace := newNodeyMcNodeFace(parent, "id", "name", p, true)
nodeFace := newNodeyMcNodeFace(parent, "id", "name", loc, true)
assert.Equal(t, parent, nodeFace.parent)
assert.Equal(t, "id", nodeFace.id)
assert.Equal(t, "name", nodeFace.name)
assert.Equal(t, p, nodeFace.prev)
assert.Equal(t, loc, nodeFace.prev)
assert.True(t, nodeFace.isPackage)
assert.NotNil(t, nodeFace.childDirs)
assert.NotNil(t, nodeFace.children)
assert.NotNil(t, nodeFace.items)
}
// note that this test is focused on the SetFolder function,
// and intentionally does not verify the resulting node tree
func (suite *DeltaTreeUnitSuite) TestFolderyMcFolderFace_SetFolder() {
treeWithRoot := func() *folderyMcFolderFace {
rootey := newNodeyMcNodeFace(nil, odConsts.RootID, odConsts.RootPathDir, nil, false)
tree := newFolderyMcFolderFace(nil)
tree.root = rootey
tree.folderIDToNode[odConsts.RootID] = rootey
return tree
}
treeWithTombstone := func() *folderyMcFolderFace {
tree := treeWithRoot()
tree.tombstones["folder"] = newNodeyMcNodeFace(nil, "folder", "", path.NewElements(""), false)
return tree
}
treeWithFolders := func() *folderyMcFolderFace {
tree := treeWithRoot()
o := newNodeyMcNodeFace(tree.root, "other", "o", nil, true)
tree.folderIDToNode[o.id] = o
f := newNodeyMcNodeFace(o, "folder", "f", nil, false)
tree.folderIDToNode[f.id] = f
o.children[f.id] = f
return tree
}
table := []struct {
tname string
tree *folderyMcFolderFace
parentID string
id string
name string
isPackage bool
expectErr assert.ErrorAssertionFunc
expectPrev assert.ValueAssertionFunc
}{
{
tname: "add root",
tree: &folderyMcFolderFace{
folderIDToNode: map[string]*nodeyMcNodeFace{},
},
id: odConsts.RootID,
name: odConsts.RootPathDir,
isPackage: true,
expectErr: assert.NoError,
expectPrev: assert.Nil,
},
{
tname: "root already exists",
tree: treeWithRoot(),
id: odConsts.RootID,
name: odConsts.RootPathDir,
expectErr: assert.NoError,
expectPrev: assert.Nil,
},
{
tname: "add folder",
tree: treeWithRoot(),
parentID: odConsts.RootID,
id: "folder",
name: "nameyMcNameFace",
expectErr: assert.NoError,
expectPrev: assert.Nil,
},
{
tname: "add package",
tree: treeWithRoot(),
parentID: odConsts.RootID,
id: "folder",
name: "nameyMcNameFace",
isPackage: true,
expectErr: assert.NoError,
expectPrev: assert.Nil,
},
{
tname: "missing ID",
tree: treeWithRoot(),
parentID: odConsts.RootID,
name: "nameyMcNameFace",
isPackage: true,
expectErr: assert.Error,
expectPrev: assert.Nil,
},
{
tname: "missing name",
tree: treeWithRoot(),
parentID: odConsts.RootID,
id: "folder",
isPackage: true,
expectErr: assert.Error,
expectPrev: assert.Nil,
},
{
tname: "missing parentID",
tree: treeWithRoot(),
id: "folder",
name: "nameyMcNameFace",
isPackage: true,
expectErr: assert.Error,
expectPrev: assert.Nil,
},
{
tname: "already tombstoned",
tree: treeWithTombstone(),
parentID: odConsts.RootID,
id: "folder",
name: "nameyMcNameFace",
expectErr: assert.NoError,
expectPrev: assert.NotNil,
},
{
tname: "add folder before parent",
tree: &folderyMcFolderFace{
folderIDToNode: map[string]*nodeyMcNodeFace{},
},
parentID: odConsts.RootID,
id: "folder",
name: "nameyMcNameFace",
isPackage: true,
expectErr: assert.Error,
expectPrev: assert.Nil,
},
{
tname: "folder already exists",
tree: treeWithFolders(),
parentID: "other",
id: "folder",
name: "nameyMcNameFace",
expectErr: assert.NoError,
expectPrev: assert.Nil,
},
}
for _, test := range table {
suite.Run(test.tname, func() {
t := suite.T()
ctx, flush := tester.NewContext(t)
defer flush()
err := test.tree.SetFolder(
ctx,
test.parentID,
test.id,
test.name,
test.isPackage)
test.expectErr(t, err, clues.ToCore(err))
if err != nil {
return
}
result := test.tree.folderIDToNode[test.id]
require.NotNil(t, result)
test.expectPrev(t, result.prev)
assert.Equal(t, test.id, result.id)
assert.Equal(t, test.name, result.name)
assert.Equal(t, test.isPackage, result.isPackage)
_, ded := test.tree.tombstones[test.id]
assert.False(t, ded)
if len(test.parentID) > 0 {
parent := test.tree.folderIDToNode[test.parentID]
assert.Equal(t, parent, result.parent)
}
})
}
}
func (suite *DeltaTreeUnitSuite) TestFolderyMcFolderFace_AddTombstone() {
loc := path.NewElements("root:/foo/bar/baz/qux/fnords/smarf/voi/zumba/bangles/howdyhowdyhowdy")
treeWithTombstone := func() *folderyMcFolderFace {
tree := newFolderyMcFolderFace(nil)
tree.tombstones["id"] = newNodeyMcNodeFace(nil, "id", "", loc, false)
return tree
}
table := []struct {
name string
id string
loc path.Elements
tree *folderyMcFolderFace
expectErr assert.ErrorAssertionFunc
}{
{
name: "add tombstone",
id: "id",
loc: loc,
tree: newFolderyMcFolderFace(nil),
expectErr: assert.NoError,
},
{
name: "duplicate tombstone",
id: "id",
loc: loc,
tree: treeWithTombstone(),
expectErr: assert.NoError,
},
{
name: "missing ID",
loc: loc,
tree: newFolderyMcFolderFace(nil),
expectErr: assert.Error,
},
{
name: "missing loc",
id: "id",
tree: newFolderyMcFolderFace(nil),
expectErr: assert.Error,
},
{
name: "empty loc",
id: "id",
loc: path.Elements{},
tree: newFolderyMcFolderFace(nil),
expectErr: assert.Error,
},
{
name: "conflict: folder alive",
id: "id",
loc: loc,
tree: treeWithTombstone(),
expectErr: assert.NoError,
},
{
name: "already tombstoned with different path",
id: "id",
loc: append(path.Elements{"foo"}, loc...),
tree: treeWithTombstone(),
expectErr: assert.NoError,
},
}
for _, test := range table {
suite.Run(test.name, func() {
t := suite.T()
ctx, flush := tester.NewContext(t)
defer flush()
err := test.tree.SetTombstone(ctx, test.id, test.loc)
test.expectErr(t, err, clues.ToCore(err))
if err != nil {
return
}
result := test.tree.tombstones[test.id]
require.NotNil(t, result)
require.NotEmpty(t, result.prev)
assert.Equal(t, loc, result.prev)
})
}
}
// ---------------------------------------------------------------------------
// tree structure assertions tests
// ---------------------------------------------------------------------------
type assertNode struct {
self *nodeyMcNodeFace
children []assertNode
}
func an(
self *nodeyMcNodeFace,
children ...assertNode,
) assertNode {
return assertNode{
self: self,
children: children,
}
}
func (an assertNode) compare(
t *testing.T,
tree *folderyMcFolderFace,
checkLiveNodeCount bool,
) {
var nodeCount int
t.Run("assert_tree_shape/root", func(_t *testing.T) {
nodeCount = compareNodes(_t, tree.root, an)
})
if checkLiveNodeCount {
require.Len(t, tree.folderIDToNode, nodeCount, "total count of live nodes")
}
}
func compareNodes(
t *testing.T,
node *nodeyMcNodeFace,
expect assertNode,
) int {
// ensure the nodes match
require.NotNil(t, node, "node does not exist in tree")
require.Equal(
t,
expect.self,
node,
"non-matching node")
// ensure the node has the expected number of children
assert.Len(
t,
node.children,
len(expect.children),
"node has expected number of children")
var nodeCount int
for _, expectChild := range expect.children {
t.Run(expectChild.self.id, func(_t *testing.T) {
nodeChild := node.children[expectChild.self.id]
require.NotNilf(
_t,
nodeChild,
"child must exist with id %q",
expectChild.self.id)
// ensure each child points to the current node as its parent
assert.Equal(
_t,
node,
nodeChild.parent,
"should point to correct parent")
// recurse
nodeCount += compareNodes(_t, nodeChild, expectChild)
})
}
return nodeCount + 1
}
type tombs []assertNode
func entomb(nodes ...assertNode) tombs {
if len(nodes) == 0 {
return tombs{}
}
return append(tombs{}, nodes...)
}
func (ts tombs) compare(
t *testing.T,
tombstones map[string]*nodeyMcNodeFace,
) {
require.Len(t, tombstones, len(ts), "count of tombstoned nodes")
for _, entombed := range ts {
zombey := tombstones[entombed.self.id]
require.NotNil(t, zombey, "tombstone must exist")
assert.Nil(t, zombey.parent, "tombstoned node should not have a parent reference")
t.Run("assert_tombstones/"+zombey.id, func(_t *testing.T) {
compareNodes(_t, zombey, entombed)
})
}
}
// unlike the prior test, this focuses entirely on whether or not the
// tree produced by folder additions is correct.
func (suite *DeltaTreeUnitSuite) TestFolderyMcFolderFace_SetFolder_correctTree() {
t := suite.T()
ctx, flush := tester.NewContext(t)
defer flush()
tree := newFolderyMcFolderFace(nil)
rootID := odConsts.RootID
set := func(
parentID, id, name string,
isPackage bool,
) {
err := tree.SetFolder(ctx, parentID, id, name, isPackage)
require.NoError(t, err, clues.ToCore(err))
}
assert.Nil(t, tree.root)
assert.Empty(t, tree.folderIDToNode)
// add the root
set("", rootID, odConsts.RootPathDir, false)
assert.NotNil(t, tree.root)
assert.Equal(t, rootID, tree.root.id)
assert.Equal(t, rootID, tree.folderIDToNode[rootID].id)
an(tree.root).compare(t, tree, true)
// add a child at the root
set(tree.root.id, "lefty", "l", false)
lefty := tree.folderIDToNode["lefty"]
an(
tree.root,
an(lefty)).
compare(t, tree, true)
// add another child at the root
set(tree.root.id, "righty", "r", false)
righty := tree.folderIDToNode["righty"]
an(
tree.root,
an(lefty),
an(righty)).
compare(t, tree, true)
// add a child to lefty
set(lefty.id, "bloaty", "bl", false)
bloaty := tree.folderIDToNode["bloaty"]
an(
tree.root,
an(lefty, an(bloaty)),
an(righty)).
compare(t, tree, true)
// add another child to lefty
set(lefty.id, "brightly", "br", false)
brightly := tree.folderIDToNode["brightly"]
an(
tree.root,
an(lefty, an(bloaty), an(brightly)),
an(righty)).
compare(t, tree, true)
// relocate brightly underneath righty
set(righty.id, brightly.id, brightly.name, false)
an(
tree.root,
an(lefty, an(bloaty)),
an(righty, an(brightly))).
compare(t, tree, true)
// relocate righty and subtree beneath lefty
set(lefty.id, righty.id, righty.name, false)
an(
tree.root,
an(
lefty,
an(bloaty),
an(righty, an(brightly)))).
compare(t, tree, true)
}
// this test focuses on whether the tree is correct when bouncing back and forth
// between live and tombstoned states on the same folder
func (suite *DeltaTreeUnitSuite) TestFolderyMcFolderFace_SetFolder_correctTombstones() {
t := suite.T()
ctx, flush := tester.NewContext(t)
defer flush()
tree := newFolderyMcFolderFace(nil)
rootID := odConsts.RootID
set := func(
parentID, id, name string,
isPackage bool,
) {
err := tree.SetFolder(ctx, parentID, id, name, isPackage)
require.NoError(t, err, clues.ToCore(err))
}
tomb := func(
id string,
loc path.Elements,
) {
err := tree.SetTombstone(ctx, id, loc)
require.NoError(t, err, clues.ToCore(err))
}
assert.Nil(t, tree.root)
assert.Empty(t, tree.folderIDToNode)
// create a simple tree
// root > branchy > [leafy, bob]
set("", rootID, odConsts.RootPathDir, false)
set(tree.root.id, "branchy", "br", false)
branchy := tree.folderIDToNode["branchy"]
set(branchy.id, "leafy", "l", false)
set(branchy.id, "bob", "bobbers", false)
leafy := tree.folderIDToNode["leafy"]
bob := tree.folderIDToNode["bob"]
an(
tree.root,
an(
branchy,
an(leafy),
an(bob))).
compare(t, tree, true)
entomb().compare(t, tree.tombstones)
var (
branchyLoc = path.NewElements("root/branchy")
leafyLoc = path.NewElements("root/branchy/leafy")
bobLoc = path.NewElements("root/branchy/bob")
)
// tombstone bob
tomb(bob.id, bobLoc)
an(
tree.root,
an(branchy, an(leafy))).
compare(t, tree, true)
entomb(an(bob)).compare(t, tree.tombstones)
// tombstone leafy
tomb(leafy.id, leafyLoc)
an(
tree.root,
an(branchy)).
compare(t, tree, true)
entomb(an(bob), an(leafy)).compare(t, tree.tombstones)
// resurrect leafy
set(branchy.id, leafy.id, leafy.name, false)
an(
tree.root,
an(branchy, an(leafy))).
compare(t, tree, true)
entomb(an(bob)).compare(t, tree.tombstones)
// resurrect bob
set(branchy.id, bob.id, bob.name, false)
an(
tree.root,
an(
branchy,
an(leafy),
an(bob))).
compare(t, tree, true)
entomb().compare(t, tree.tombstones)
// tombstone branchy
tomb(branchy.id, branchyLoc)
an(tree.root).compare(t, tree, false)
// note: the folder count here *will be wrong*.
// since we've only tombstoned branchy, both leafy
// and bob will remain in the folderIDToNode map.
// If this were real graph behavior, the delete would
// necessarily cascade and those children would get
// tombstoned next.
// So we skip the check here, just to minimize code.
// It's safe to do so, for the scope of this test.
// This should be part of the consideration for prev-
// path iteration that could create improper state in
// the post-processing stage if we're nott careful.
entomb(
an(
branchy,
an(leafy),
an(bob))).
compare(t, tree.tombstones)
// resurrect branchy
set(tree.root.id, branchy.id, branchy.name, false)
an(
tree.root,
an(
branchy,
an(leafy),
an(bob))).
compare(t, tree, true)
entomb().compare(t, tree.tombstones)
// tombstone branchy
tomb(branchy.id, branchyLoc)
an(tree.root).compare(t, tree, false)
entomb(
an(
branchy,
an(leafy),
an(bob))).
compare(t, tree.tombstones)
// tombstone bob
tomb(bob.id, bobLoc)
an(tree.root).compare(t, tree, false)
entomb(
an(branchy, an(leafy)),
an(bob)).
compare(t, tree.tombstones)
// resurrect branchy
set(tree.root.id, branchy.id, branchy.name, false)
an(
tree.root,
an(branchy, an(leafy))).
compare(t, tree, false)
entomb(an(bob)).compare(t, tree.tombstones)
// resurrect bob
set(branchy.id, bob.id, bob.name, false)
an(
tree.root,
an(
branchy,
an(leafy),
an(bob))).
compare(t, tree, true)
entomb().compare(t, tree.tombstones)
}