diff --git a/pkg/document/change/context.go b/pkg/document/change/context.go
index c494644fb..79d9585fb 100644
--- a/pkg/document/change/context.go
+++ b/pkg/document/change/context.go
@@ -85,6 +85,11 @@ func (c *Context) RegisterElementHasRemovedNodes(element crdt.GCElement) {
c.root.RegisterElementHasRemovedNodes(element)
}
+// RegisterGCNodePairMapByID register the given GCNode pair to hash table.
+func (c *Context) RegisterGCNodePairMapByID(key string, parent crdt.GCNode, child crdt.GCNode) {
+ c.root.RegisterGCNodePairMapByID(key, parent, child)
+}
+
// LastTimeTicket returns the last time ticket issued by this context.
func (c *Context) LastTimeTicket() *time.Ticket {
return c.id.NewTimeTicket(c.delimiter)
diff --git a/pkg/document/crdt/element_rht.go b/pkg/document/crdt/element_rht.go
index 8186bc3f0..ff5b0feb6 100644
--- a/pkg/document/crdt/element_rht.go
+++ b/pkg/document/crdt/element_rht.go
@@ -110,7 +110,7 @@ func (rht *ElementRHT) Set(k string, v Element) Element {
if !ok || v.CreatedAt().After(node.elem.CreatedAt()) {
rht.nodeMapByKey[k] = newNode
}
-
+ // TODO(raararaara): Overwritten node does not need to be purged.
return removed
}
diff --git a/pkg/document/crdt/gc_node.go b/pkg/document/crdt/gc_node.go
new file mode 100644
index 000000000..2b50956bb
--- /dev/null
+++ b/pkg/document/crdt/gc_node.go
@@ -0,0 +1,15 @@
+package crdt
+
+import "github.com/yorkie-team/yorkie/pkg/document/time"
+
+// GCNode represents a common node with GC.
+type GCNode interface {
+ // GetID returns the IDString of this node.
+ GetID() string
+
+ // GetRemovedAt returns the removal time of this node.
+ GetRemovedAt() *time.Ticket
+
+ // Purge physically purges the given child of this node.
+ Purge(node GCNode, ticket *time.Ticket) (int, error)
+}
diff --git a/pkg/document/crdt/rht.go b/pkg/document/crdt/rht.go
index bc424e18e..a10199ab8 100644
--- a/pkg/document/crdt/rht.go
+++ b/pkg/document/crdt/rht.go
@@ -41,6 +41,18 @@ func newRHTNode(key, val string, updatedAt *time.Ticket, isRemoved bool) *RHTNod
}
}
+// Remove removes this node. It marks RHTNode as removed and updates the value of `updatedAt` (tombstone).
+func (n *RHTNode) Remove(removedAt *time.Ticket) bool {
+ if removedAt != nil && removedAt.After(n.UpdatedAt()) &&
+ !n.isRemoved || removedAt.After(n.UpdatedAt()) {
+ n.isRemoved = true
+ n.updatedAt = removedAt
+
+ return true
+ }
+ return false
+}
+
// Key returns the key of this node.
func (n *RHTNode) Key() string {
return n.key
@@ -56,19 +68,38 @@ func (n *RHTNode) UpdatedAt() *time.Ticket {
return n.updatedAt
}
+// GetID returns the IDString of this node.
+func (n *RHTNode) GetID() string {
+ return n.updatedAt.Key() + ":" + n.key
+}
+
+// GetRemovedAt returns the removal time of this node.
+func (n *RHTNode) GetRemovedAt() *time.Ticket {
+ if n.isRemoved {
+ return n.updatedAt
+ }
+ return nil
+}
+
+// Purge physically purges children of given node.
+// NOTE(raararaara): This method was added only for consistency.
+// It will not be used unless additional children of RHTNode are implemented.
+func (n *RHTNode) Purge(_ GCNode, _ *time.Ticket) (int, error) {
+ return 0, nil
+}
+
// RHT is a hashtable with logical clock(Replicated hashtable).
// For more details about RHT: http://csl.skku.edu/papers/jpdc11.pdf
// NOTE(justiceHui): RHT and ElementRHT has duplicated functions.
type RHT struct {
- nodeMapByKey map[string]*RHTNode
- numberOfRemovedElement int
+ // nodeMapByKey is a map with values of nodes by key.
+ nodeMapByKey map[string]*RHTNode
}
// NewRHT creates a new instance of RHT.
func NewRHT() *RHT {
return &RHT{
- nodeMapByKey: make(map[string]*RHTNode),
- numberOfRemovedElement: 0,
+ nodeMapByKey: make(map[string]*RHTNode),
}
}
@@ -96,39 +127,27 @@ func (rht *RHT) Has(key string) bool {
// Set sets the value of the given key.
func (rht *RHT) Set(k, v string, executedAt *time.Ticket) {
if node, ok := rht.nodeMapByKey[k]; !ok || executedAt.After(node.updatedAt) {
- if node != nil && node.isRemoved {
- rht.numberOfRemovedElement--
- }
newNode := newRHTNode(k, v, executedAt, false)
rht.nodeMapByKey[k] = newNode
}
}
// Remove removes the Element of the given key.
-func (rht *RHT) Remove(k string, executedAt *time.Ticket) string {
- if node, ok := rht.nodeMapByKey[k]; !ok || executedAt.After(node.updatedAt) {
- // NOTE(justiceHui): Even if key is not existed, we must set flag `isRemoved` for concurrency
- if node == nil {
- rht.numberOfRemovedElement++
+func (rht *RHT) Remove(k string, executedAt *time.Ticket) *RHTNode {
+ if node, ok := rht.nodeMapByKey[k]; !ok || node.Remove(executedAt) {
+ if !ok {
newNode := newRHTNode(k, ``, executedAt, true)
rht.nodeMapByKey[k] = newNode
- return ""
- }
- alreadyRemoved := node.isRemoved
- if !alreadyRemoved {
- rht.numberOfRemovedElement++
+ return newNode
}
newNode := newRHTNode(k, node.val, executedAt, true)
rht.nodeMapByKey[k] = newNode
- if alreadyRemoved {
- return ""
- }
- return node.val
+ return newNode
}
- return ""
+ return nil
}
// Elements returns a map of elements because the map easy to use for loop.
@@ -144,22 +163,35 @@ func (rht *RHT) Elements() map[string]string {
return members
}
-// Nodes returns a map of elements because the map easy to use for loop.
+// Nodes returns a list of RHTNodes.
// TODO: If we encounter performance issues, we need to replace this with other solution.
func (rht *RHT) Nodes() []*RHTNode {
var nodes []*RHTNode
for _, node := range rht.nodeMapByKey {
- if !node.isRemoved {
- nodes = append(nodes, node)
- }
+ nodes = append(nodes, node)
}
return nodes
}
+func (rht *RHT) purge(key string, ticket *time.Ticket) (int, error) {
+ if node, ok := rht.nodeMapByKey[key]; ok && ticket.After(node.updatedAt) {
+ delete(rht.nodeMapByKey, key)
+ return 1, nil
+ }
+
+ return 0, nil
+}
+
// Len returns the number of elements.
func (rht *RHT) Len() int {
- return len(rht.nodeMapByKey) - rht.numberOfRemovedElement
+ count := 0
+ for _, node := range rht.Nodes() {
+ if !node.isRemoved {
+ count++
+ }
+ }
+ return count
}
// DeepCopy copies itself deeply.
diff --git a/pkg/document/crdt/rht_test.go b/pkg/document/crdt/rht_test.go
index f7c90f88c..f4cbcb898 100644
--- a/pkg/document/crdt/rht_test.go
+++ b/pkg/document/crdt/rht_test.go
@@ -141,84 +141,103 @@ func TestRHT_Remove(t *testing.T) {
key2, val2, val22 := `key2`, `value2`, `value22`
tests := []struct {
- desc string
- insertKey []string
- insertVal []string
- deleteKey []string
- deleteVal []string
- expectXML string
- expectJSON string
- expectSize int
+ desc string
+ insertKey []string
+ insertVal []string
+ deleteKey []string
+ deleteVal []string
+ expectXML string
+ expectJSON string
+ expectSize int
+ expectTotalSize int
}{
{
- desc: `1. set elements`,
- insertKey: []string{key1, key2},
- insertVal: []string{val1, val2},
- deleteKey: []string{},
- deleteVal: []string{},
- expectXML: `key1="value1" key2="value2"`,
- expectJSON: `{"key1":"value1","key2":"value2"}`,
- expectSize: 2,
+ desc: `1. set single element`,
+ insertKey: []string{key1},
+ insertVal: []string{val1},
+ deleteKey: []string{},
+ deleteVal: []string{},
+ expectXML: `key1="value1"`,
+ expectJSON: `{"key1":"value1"}`,
+ expectSize: 1,
+ expectTotalSize: 1,
},
{
- desc: `2. remove element`,
- insertKey: []string{},
- insertVal: []string{},
- deleteKey: []string{key1},
- deleteVal: []string{val1},
- expectXML: `key2="value2"`,
- expectJSON: `{"key2":"value2"}`,
- expectSize: 1,
+ desc: `2. set another element`,
+ insertKey: []string{key2},
+ insertVal: []string{val2},
+ deleteKey: []string{},
+ deleteVal: []string{},
+ expectXML: `key1="value1" key2="value2"`,
+ expectJSON: `{"key1":"value1","key2":"value2"}`,
+ expectSize: 2,
+ expectTotalSize: 2,
},
{
- desc: `3. set after remove`,
- insertKey: []string{key1},
- insertVal: []string{val11},
- deleteKey: []string{},
- deleteVal: []string{},
- expectXML: `key1="value11" key2="value2"`,
- expectJSON: `{"key1":"value11","key2":"value2"}`,
- expectSize: 2,
+ desc: `3. remove element`,
+ insertKey: []string{},
+ insertVal: []string{},
+ deleteKey: []string{key1},
+ deleteVal: []string{val1},
+ expectXML: `key2="value2"`,
+ expectJSON: `{"key2":"value2"}`,
+ expectSize: 1,
+ expectTotalSize: 2,
+ },
+ {
+ desc: `4. set after remove`,
+ insertKey: []string{key1},
+ insertVal: []string{val11},
+ deleteKey: []string{},
+ deleteVal: []string{},
+ expectXML: `key1="value11" key2="value2"`,
+ expectJSON: `{"key1":"value11","key2":"value2"}`,
+ expectSize: 2,
+ expectTotalSize: 2,
+ },
+ {
+ desc: `5. remove element`,
+ insertKey: []string{key2},
+ insertVal: []string{val22},
+ deleteKey: []string{key1},
+ deleteVal: []string{val11},
+ expectXML: `key2="value22"`,
+ expectJSON: `{"key2":"value22"}`,
+ expectSize: 1,
+ expectTotalSize: 2,
+ },
+ {
+ desc: `6. remove element again`,
+ insertKey: []string{},
+ insertVal: []string{},
+ deleteKey: []string{key1},
+ deleteVal: []string{val11},
+ expectXML: `key2="value22"`,
+ expectJSON: `{"key2":"value22"}`,
+ expectSize: 1,
+ expectTotalSize: 2,
+ },
+ {
+ desc: `7. remove element(cleared)`,
+ insertKey: []string{},
+ insertVal: []string{},
+ deleteKey: []string{key2},
+ deleteVal: []string{val22},
+ expectXML: ``,
+ expectJSON: `{}`,
+ expectSize: 0,
+ expectTotalSize: 2,
},
{
- desc: `4. remove element`,
- insertKey: []string{key2},
- insertVal: []string{val22},
- deleteKey: []string{key1},
- deleteVal: []string{val11},
- expectXML: `key2="value22"`,
- expectJSON: `{"key2":"value22"}`,
- expectSize: 1,
- },
- {
- desc: `5. remove element again`,
- insertKey: []string{},
- insertVal: []string{},
- deleteKey: []string{key1},
- deleteVal: []string{``},
- expectXML: `key2="value22"`,
- expectJSON: `{"key2":"value22"}`,
- expectSize: 1,
- },
- {
- desc: `6. remove element(cleared)`,
- insertKey: []string{},
- insertVal: []string{},
- deleteKey: []string{key2},
- deleteVal: []string{val22},
- expectXML: ``,
- expectJSON: `{}`,
- expectSize: 0,
- },
- {
- desc: `7. remove not exist key`,
- insertKey: []string{},
- insertVal: []string{},
- deleteKey: []string{`not-exist-key`},
- deleteVal: []string{``},
- expectXML: ``,
- expectJSON: `{}`,
- expectSize: 0,
+ desc: `8. remove not exist key`,
+ insertKey: []string{},
+ insertVal: []string{},
+ deleteKey: []string{`not-exist-key`},
+ deleteVal: []string{``},
+ expectXML: ``,
+ expectJSON: `{}`,
+ expectSize: 0,
+ expectTotalSize: 3,
},
}
@@ -234,13 +253,15 @@ func TestRHT_Remove(t *testing.T) {
}
for i, key := range tt.deleteKey {
removedElement := rht.Remove(key, ctx.IssueTimeTicket())
- assert.Equal(t, tt.deleteVal[i], removedElement)
+ if removedElement != nil {
+ assert.Equal(t, tt.deleteVal[i], removedElement.Value())
+ }
}
assert.Equal(t, tt.expectXML, rht.ToXML())
assert.Equal(t, tt.expectJSON, rht.Marshal())
assert.Equal(t, tt.expectSize, rht.Len())
- assert.Equal(t, tt.expectSize, len(rht.Nodes()))
assert.Equal(t, tt.expectSize, len(rht.Elements()))
+ assert.Equal(t, tt.expectTotalSize, len(rht.Nodes()))
})
}
}
diff --git a/pkg/document/crdt/root.go b/pkg/document/crdt/root.go
index 73db7de88..59fc35dfd 100644
--- a/pkg/document/crdt/root.go
+++ b/pkg/document/crdt/root.go
@@ -29,6 +29,13 @@ type ElementPair struct {
elem Element
}
+// GCPair represents pair that has a parent GCNode and child GCNode.
+// Actual GC target is child GCNode.
+type GCPair struct {
+ Parent GCNode
+ Child GCNode
+}
+
// Root is a structure represents the root of JSON. It has a hash table of
// all JSON elements to find a specific element when applying remote changes
// received from server.
@@ -40,6 +47,7 @@ type Root struct {
elementMapByCreatedAt map[string]Element
removedElementPairMapByCreatedAt map[string]ElementPair
elementHasRemovedNodesSetByCreatedAt map[string]GCElement
+ gcNodePairMapByID map[string]GCPair
}
// NewRoot creates a new instance of Root.
@@ -48,6 +56,7 @@ func NewRoot(root *Object) *Root {
elementMapByCreatedAt: make(map[string]Element),
removedElementPairMapByCreatedAt: make(map[string]ElementPair),
elementHasRemovedNodesSetByCreatedAt: make(map[string]GCElement),
+ gcNodePairMapByID: make(map[string]GCPair),
}
r.object = root
@@ -128,6 +137,14 @@ func (r *Root) RegisterElementHasRemovedNodes(element GCElement) {
r.elementHasRemovedNodesSetByCreatedAt[element.CreatedAt().Key()] = element
}
+// RegisterGCNodePairMapByID register the given GCNode pair to hash table.
+func (r *Root) RegisterGCNodePairMapByID(key string, parent GCNode, child GCNode) {
+ r.gcNodePairMapByID[key] = GCPair{
+ parent,
+ child,
+ }
+}
+
// DeepCopy copies itself deeply.
func (r *Root) DeepCopy() (*Root, error) {
copiedObject, err := r.object.DeepCopy()
@@ -141,6 +158,16 @@ func (r *Root) DeepCopy() (*Root, error) {
func (r *Root) GarbageCollect(ticket *time.Ticket) (int, error) {
count := 0
+ for key, pair := range r.gcNodePairMapByID {
+ if pair.Child.GetRemovedAt() != nil && ticket.After(pair.Child.GetRemovedAt()) {
+ purgedNodes, err := pair.Parent.Purge(pair.Child, ticket)
+ if err != nil {
+ return 0, err
+ }
+ count += purgedNodes
+ delete(r.gcNodePairMapByID, key)
+ }
+ }
for _, pair := range r.removedElementPairMapByCreatedAt {
if pair.elem.RemovedAt() != nil && ticket.Compare(pair.elem.RemovedAt()) >= 0 {
if err := pair.parent.Purge(pair.elem); err != nil {
@@ -195,6 +222,8 @@ func (r *Root) GarbageLen() int {
count += len(seen)
+ count += len(r.gcNodePairMapByID)
+
for _, element := range r.elementHasRemovedNodesSetByCreatedAt {
count += element.removedNodesLen()
}
diff --git a/pkg/document/crdt/tree.go b/pkg/document/crdt/tree.go
index ad160a420..370402d3a 100644
--- a/pkg/document/crdt/tree.go
+++ b/pkg/document/crdt/tree.go
@@ -218,6 +218,16 @@ func (n *TreeNode) Child(offset int) (*TreeNode, error) {
return child.Value, nil
}
+// GetID returns the IDString of this node.
+func (n *TreeNode) GetID() string {
+ return n.ID.toIDString()
+}
+
+// GetRemovedAt returns the removal time of this TreeNode.
+func (n *TreeNode) GetRemovedAt() *time.Ticket {
+ return n.RemovedAt
+}
+
// Split splits the node at the given offset.
func (n *TreeNode) Split(tree *Tree, offset int, issueTimeTicket func() *time.Ticket) error {
var split *TreeNode
@@ -386,6 +396,25 @@ func (n *TreeNode) InsertAfter(content *TreeNode, children *TreeNode) error {
return n.Index.InsertAfter(content.Index, children.Index)
}
+// Purge physically purges RHTNode that have been removed.
+func (n *TreeNode) Purge(node GCNode, executedAt *time.Ticket) (int, error) {
+ if n.Attrs == nil {
+ return 0, nil
+ }
+
+ if rhtNode, ok := node.(*RHTNode); ok {
+ key := rhtNode.key
+
+ count, err := n.Attrs.purge(key, executedAt)
+ if err != nil {
+ return 0, err
+ }
+ return count, err
+ }
+
+ return 0, nil
+}
+
// Tree represents the tree of CRDT. It has doubly linked list structure and
// index tree structure.
type Tree struct {
@@ -877,15 +906,21 @@ func (t *Tree) Style(from, to *TreePos, attributes map[string]string, editedAt *
}
// RemoveStyle removes the given attributes of the given range.
-func (t *Tree) RemoveStyle(from, to *TreePos, attributesToRemove []string, editedAt *time.Ticket) error {
+func (t *Tree) RemoveStyle(
+ from, to *TreePos,
+ attributesToRemove []string,
+ editedAt *time.Ticket,
+) (map[string]GCPair, error) {
+ gcPair := make(map[string]GCPair)
+ //nodeHasRemovedRHTNodes := make([]*TreeNode, 0)
// 01. split text nodes at the given range if needed.
fromParent, fromLeft, err := t.FindTreeNodesWithSplitText(from, editedAt)
if err != nil {
- return err
+ return nil, err
}
toParent, toLeft, err := t.FindTreeNodesWithSplitText(to, editedAt)
if err != nil {
- return err
+ return nil, err
}
err = t.traverseInPosRange(fromParent, fromLeft, toParent, toLeft,
@@ -897,15 +932,22 @@ func (t *Tree) RemoveStyle(from, to *TreePos, attributesToRemove []string, edite
node.Attrs = NewRHT()
}
for _, value := range attributesToRemove {
- node.Attrs.Remove(value, editedAt)
+ if removedRhtNode := node.Attrs.Remove(value, editedAt); removedRhtNode != nil {
+ // NOTE(raararaara): RHTNode does not have a unique key.
+ // Therefore, it can be made unique by making it dependent on the ID of the TreeNode.
+ gcPair[node.GetID()+":"+removedRhtNode.GetID()] = GCPair{
+ node,
+ removedRhtNode,
+ }
+ }
}
}
})
if err != nil {
- return err
+ return nil, err
}
- return nil
+ return gcPair, nil
}
// FindTreeNodesWithSplitText finds TreeNode of the given crdt.TreePos and
diff --git a/pkg/document/json/tree.go b/pkg/document/json/tree.go
index 2b118acd4..431a181c0 100644
--- a/pkg/document/json/tree.go
+++ b/pkg/document/json/tree.go
@@ -256,7 +256,8 @@ func (t *Tree) RemoveStyle(fromIdx, toIdx int, attributesToRemove []string) bool
}
ticket := t.context.IssueTimeTicket()
- if err := t.Tree.RemoveStyle(fromPos, toPos, attributesToRemove, ticket); err != nil {
+ gcPair, err := t.Tree.RemoveStyle(fromPos, toPos, attributesToRemove, ticket)
+ if err != nil {
panic(err)
}
@@ -268,6 +269,10 @@ func (t *Tree) RemoveStyle(fromIdx, toIdx int, attributesToRemove []string) bool
ticket,
))
+ for key, pair := range gcPair {
+ t.context.RegisterGCNodePairMapByID(key, pair.Parent, pair.Child)
+ }
+
return true
}
diff --git a/pkg/document/operations/tree_style.go b/pkg/document/operations/tree_style.go
index dce711c04..623177498 100644
--- a/pkg/document/operations/tree_style.go
+++ b/pkg/document/operations/tree_style.go
@@ -89,8 +89,16 @@ func (e *TreeStyle) Execute(root *crdt.Root) error {
if len(e.attributes) > 0 {
return obj.Style(e.from, e.to, e.attributes, e.executedAt)
}
+ gcPair, err := obj.RemoveStyle(e.from, e.to, e.attributesToRemove, e.executedAt)
+ if err != nil {
+ return err
+ }
+
+ for k, pair := range gcPair {
+ root.RegisterGCNodePairMapByID(k, pair.Parent, pair.Child)
+ }
- return obj.RemoveStyle(e.from, e.to, e.attributesToRemove, e.executedAt)
+ return nil
}
// FromPos returns the start point of the editing range.
diff --git a/test/integration/gc_test.go b/test/integration/gc_test.go
index 0c972d5bf..364d93c13 100644
--- a/test/integration/gc_test.go
+++ b/test/integration/gc_test.go
@@ -383,6 +383,111 @@ func TestGarbageCollection(t *testing.T) {
assert.Equal(t, 6, d2.GarbageLen())
})
+ t.Run("garbage collection for tree remove style", func(t *testing.T) {
+ doc := document.New(helper.TestDocKey(t))
+
+ err := doc.Update(func(root *json.Object, p *presence.Presence) error {
+ root.SetNewTree("t", &json.TreeNode{
+ Type: "root",
+ Children: []json.TreeNode{
+ {Type: "p", Children: []json.TreeNode{{Type: "text", Value: "ab"}}},
+ {Type: "p", Attributes: map[string]string{"italic": "true", "bold": "true"}, Children: []json.TreeNode{{Type: "text", Value: "cd"}}},
+ },
+ })
+ assert.Equal(t, `ab
cd
`, root.GetTree("t").ToXML())
+
+ return nil
+ })
+ assert.NoError(t, err)
+
+ err = doc.Update(func(root *json.Object, p *presence.Presence) error {
+ root.GetTree("t").RemoveStyle(4, 8, []string{"italic"})
+ assert.Equal(t, `ab
cd
`, root.GetTree("t").ToXML())
+ return nil
+ })
+ assert.NoError(t, err)
+ assert.Equal(t, 1, doc.GarbageLen())
+ assert.Equal(t, 1, doc.GarbageCollect(time.MaxTicket))
+ assert.Equal(t, 0, doc.GarbageLen())
+ })
+
+ t.Run("garbage collection for tree remove style (multi node)", func(t *testing.T) {
+ doc := document.New(helper.TestDocKey(t))
+
+ err := doc.Update(func(root *json.Object, p *presence.Presence) error {
+ root.SetNewTree("t", &json.TreeNode{
+ Type: "root",
+ Children: []json.TreeNode{
+ {Type: "p", Attributes: map[string]string{"italic": "true"}, Children: []json.TreeNode{{Type: "text", Value: "ab"}}},
+ {Type: "p", Attributes: map[string]string{"italic": "true", "bold": "true"}, Children: []json.TreeNode{{Type: "text", Value: "cd"}}},
+ },
+ })
+ assert.Equal(t, `ab
cd
`, root.GetTree("t").ToXML())
+
+ return nil
+ })
+ assert.NoError(t, err)
+
+ err = doc.Update(func(root *json.Object, p *presence.Presence) error {
+ root.GetTree("t").RemoveStyle(0, 8, []string{"italic"})
+ assert.Equal(t, `ab
cd
`, root.GetTree("t").ToXML())
+ return nil
+ })
+ assert.NoError(t, err)
+ assert.Equal(t, 2, doc.GarbageLen())
+ assert.Equal(t, 2, doc.GarbageCollect(time.MaxTicket))
+ assert.Equal(t, 0, doc.GarbageLen())
+ })
+
+ t.Run("garbage collection for tree remove style (multi clients)", func(t *testing.T) {
+ ctx := context.Background()
+ docKey := helper.TestDocKey(t)
+ d1 := document.New(docKey)
+ err := c1.Attach(ctx, d1)
+ assert.NoError(t, err)
+
+ d2 := document.New(docKey)
+ err = c2.Attach(ctx, d2)
+ assert.NoError(t, err)
+
+ err = d1.Update(func(root *json.Object, p *presence.Presence) error {
+ root.SetNewTree("t", &json.TreeNode{
+ Type: "root",
+ Children: []json.TreeNode{
+ {Type: "p", Children: []json.TreeNode{{Type: "text", Value: "ab"}}},
+ {Type: "p", Attributes: map[string]string{"bold": "true", "italic": "true"}, Children: []json.TreeNode{{Type: "text", Value: "cd"}}},
+ },
+ })
+ assert.Equal(t, `ab
cd
`, root.GetTree("t").ToXML())
+
+ return nil
+ })
+ assert.NoError(t, err)
+
+ err = c1.Sync(ctx)
+ assert.NoError(t, err)
+ err = c2.Sync(ctx)
+ assert.NoError(t, err)
+
+ err = d1.Update(func(root *json.Object, p *presence.Presence) error {
+ root.GetTree("t").RemoveStyle(4, 8, []string{"italic"})
+ assert.Equal(t, `ab
cd
`, root.GetTree("t").ToXML())
+ return nil
+ })
+ assert.NoError(t, err)
+
+ assert.NoError(t, err)
+ assert.Equal(t, 1, d1.GarbageLen())
+ assert.Equal(t, 0, d2.GarbageLen())
+
+ err = c1.Sync(ctx)
+ assert.NoError(t, err)
+ err = c2.Sync(ctx)
+ assert.NoError(t, err)
+ assert.Equal(t, 1, d1.GarbageLen())
+ assert.Equal(t, 1, d2.GarbageLen())
+ })
+
t.Run("GarbageLen should return the actual number of elements garbage-collected", func(t *testing.T) {
ctx := context.Background()
docKey := helper.TestDocKey(t)