Skip to content

Commit

Permalink
Support concurrent formatting of Text (#639)
Browse files Browse the repository at this point in the history
Currently, we are unable to check for concurrent cases when applying
the Text.setStyle operation. This pull request introduces a map called
latestCreatedAtMapByActor to track the causality between the
operations of the two clients and ensures that the results converge
into one.
  • Loading branch information
MoonGyu1 authored and hackerwins committed Sep 13, 2023
1 parent 3d10ce5 commit 940941a
Show file tree
Hide file tree
Showing 10 changed files with 507 additions and 190 deletions.
7 changes: 7 additions & 0 deletions api/converter/from_pb.go
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,12 @@ func fromStyle(pbStyle *api.Operation_Style) (*operations.Style, error) {
if err != nil {
return nil, err
}
createdAtMapByActor, err := fromCreatedAtMapByActor(
pbStyle.CreatedAtMapByActor,
)
if err != nil {
return nil, err
}
executedAt, err := fromTimeTicket(pbStyle.ExecutedAt)
if err != nil {
return nil, err
Expand All @@ -448,6 +454,7 @@ func fromStyle(pbStyle *api.Operation_Style) (*operations.Style, error) {
parentCreatedAt,
from,
to,
createdAtMapByActor,
pbStyle.Attributes,
executedAt,
), nil
Expand Down
11 changes: 6 additions & 5 deletions api/converter/to_pb.go
Original file line number Diff line number Diff line change
Expand Up @@ -354,11 +354,12 @@ func toEdit(e *operations.Edit) (*api.Operation_Edit_, error) {
func toStyle(style *operations.Style) (*api.Operation_Style_, error) {
return &api.Operation_Style_{
Style: &api.Operation_Style{
ParentCreatedAt: ToTimeTicket(style.ParentCreatedAt()),
From: toTextNodePos(style.From()),
To: toTextNodePos(style.To()),
Attributes: style.Attributes(),
ExecutedAt: ToTimeTicket(style.ExecutedAt()),
ParentCreatedAt: ToTimeTicket(style.ParentCreatedAt()),
From: toTextNodePos(style.From()),
To: toTextNodePos(style.To()),
CreatedAtMapByActor: toCreatedAtMapByActor(style.CreatedAtMapByActor()),
Attributes: style.Attributes(),
ExecutedAt: ToTimeTicket(style.ExecutedAt()),
},
}, nil
}
Expand Down
522 changes: 350 additions & 172 deletions api/yorkie/v1/resources.pb.go

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions api/yorkie/v1/resources.proto
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ message Operation {
TextNodePos to = 3;
map<string, string> attributes = 4;
TimeTicket executed_at = 5;
map<string, TimeTicket> created_at_map_by_actor = 6;
}
message Increase {
TimeTicket parent_created_at = 1;
Expand Down
6 changes: 6 additions & 0 deletions pkg/document/crdt/rga_tree_split.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,12 @@ func (s *RGATreeSplitNode[V]) Remove(removedAt *time.Ticket, latestCreatedAt *ti
return false
}

// canStyle checks if node is able to set style.
func (s *RGATreeSplitNode[V]) canStyle(editedAt *time.Ticket, latestCreatedAt *time.Ticket) bool {
return !s.createdAt().After(latestCreatedAt) &&
(s.removedAt == nil || editedAt.After(s.removedAt))
}

// Value returns the value of this node.
func (s *RGATreeSplitNode[V]) Value() V {
return s.value
Expand Down
38 changes: 34 additions & 4 deletions pkg/document/crdt/text.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,28 +250,58 @@ func (t *Text) Edit(
func (t *Text) Style(
from,
to *RGATreeSplitNodePos,
latestCreatedAtMapByActor map[string]*time.Ticket,
attributes map[string]string,
executedAt *time.Ticket,
) error {
) (map[string]*time.Ticket, error) {
// 01. Split nodes with from and to
_, toRight, err := t.rgaTreeSplit.findNodeWithSplit(to, executedAt)
if err != nil {
return err
return nil, err
}
_, fromRight, err := t.rgaTreeSplit.findNodeWithSplit(from, executedAt)
if err != nil {
return err
return nil, err
}

// 02. style nodes between from and to
nodes := t.rgaTreeSplit.findBetween(fromRight, toRight)
createdAtMapByActor := make(map[string]*time.Ticket)
var toBeStyled []*RGATreeSplitNode[*TextValue]

for _, node := range nodes {
actorIDHex := node.id.createdAt.ActorIDHex()

var latestCreatedAt *time.Ticket
if len(latestCreatedAtMapByActor) == 0 {
latestCreatedAt = time.MaxTicket
} else {
createdAt, ok := latestCreatedAtMapByActor[actorIDHex]
if ok {
latestCreatedAt = createdAt
} else {
latestCreatedAt = time.InitialTicket
}
}

if node.canStyle(executedAt, latestCreatedAt) {
latestCreatedAt = createdAtMapByActor[actorIDHex]
createdAt := node.id.createdAt
if latestCreatedAt == nil || createdAt.After(latestCreatedAt) {
createdAtMapByActor[actorIDHex] = createdAt
}
toBeStyled = append(toBeStyled, node)
}
}

for _, node := range toBeStyled {
val := node.value
for key, value := range attributes {
val.attrs.Set(key, value, executedAt)
}
}
return nil

return createdAtMapByActor, nil
}

// Nodes returns the internal nodes of this Text.
Expand Down
2 changes: 1 addition & 1 deletion pkg/document/crdt/text_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func TestText(t *testing.T) {
assert.Equal(t, `[{"val":"Hello "},{"val":"Yorkie"}]`, text.Marshal())

fromPos, toPos, _ = text.CreateRange(0, 1)
err = text.Style(fromPos, toPos, map[string]string{"b": "1"}, ctx.IssueTimeTicket())
_, err = text.Style(fromPos, toPos, nil, map[string]string{"b": "1"}, ctx.IssueTimeTicket())
assert.NoError(t, err)
assert.Equal(
t,
Expand Down
7 changes: 5 additions & 2 deletions pkg/document/json/text.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,19 +103,22 @@ func (p *Text) Style(from, to int, attributes map[string]string) *Text {
}

ticket := p.context.IssueTimeTicket()
if err := p.Text.Style(
maxCreationMapByActor, err := p.Text.Style(
fromPos,
toPos,
nil,
attributes,
ticket,
); err != nil {
)
if err != nil {
panic(err)
}

p.context.Push(operations.NewStyle(
p.CreatedAt(),
fromPos,
toPos,
maxCreationMapByActor,
attributes,
ticket,
))
Expand Down
25 changes: 19 additions & 6 deletions pkg/document/operations/style.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ type Style struct {
// to is the end point of the range to apply the style to.
to *crdt.RGATreeSplitNodePos

// latestCreatedAtMapByActor is a map that stores the latest creation time
// by actor for the nodes included in the range to apply the style to.
latestCreatedAtMapByActor map[string]*time.Ticket

// attributes represents the text style.
attributes map[string]string

Expand All @@ -44,15 +48,17 @@ func NewStyle(
parentCreatedAt *time.Ticket,
from *crdt.RGATreeSplitNodePos,
to *crdt.RGATreeSplitNodePos,
latestCreatedAtMapByActor map[string]*time.Ticket,
attributes map[string]string,
executedAt *time.Ticket,
) *Style {
return &Style{
parentCreatedAt: parentCreatedAt,
from: from,
to: to,
attributes: attributes,
executedAt: executedAt,
parentCreatedAt: parentCreatedAt,
from: from,
to: to,
latestCreatedAtMapByActor: latestCreatedAtMapByActor,
attributes: attributes,
executedAt: executedAt,
}
}

Expand All @@ -64,7 +70,8 @@ func (e *Style) Execute(root *crdt.Root) error {
return ErrNotApplicableDataType
}

return obj.Style(e.from, e.to, e.attributes, e.executedAt)
_, err := obj.Style(e.from, e.to, e.latestCreatedAtMapByActor, e.attributes, e.executedAt)
return err
}

// From returns the start point of the editing range.
Expand Down Expand Up @@ -96,3 +103,9 @@ func (e *Style) ParentCreatedAt() *time.Ticket {
func (e *Style) Attributes() map[string]string {
return e.attributes
}

// CreatedAtMapByActor returns the map that stores the latest creation time
// by actor for the nodes included in the range to apply the style to.
func (e *Style) CreatedAtMapByActor() map[string]*time.Ticket {
return e.latestCreatedAtMapByActor
}
78 changes: 78 additions & 0 deletions test/integration/text_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,43 @@ func TestText(t *testing.T) {
syncClientsThenAssertEqual(t, []clientAndDocPair{{c1, d1}, {c2, d2}})
})

t.Run("concurrent insertion and deletion test", func(t *testing.T) {
ctx := context.Background()
d1 := document.New(helper.TestDocKey(t))
err := c1.Attach(ctx, d1)
assert.NoError(t, err)

err = d1.Update(func(root *json.Object, p *presence.Presence) error {
root.SetNewText("k1").Edit(0, 0, "AB")
return nil
}, "set a new text by c1")
assert.NoError(t, err)
err = c1.Sync(ctx)
assert.NoError(t, err)

d2 := document.New(helper.TestDocKey(t))
err = c2.Attach(ctx, d2)
assert.NoError(t, err)
assert.Equal(t, `{"k1":[{"val":"AB"}]}`, d2.Marshal())

err = d1.Update(func(root *json.Object, p *presence.Presence) error {
root.GetText("k1").Edit(0, 2, "")
return nil
})
assert.NoError(t, err)
assert.Equal(t, `{"k1":[]}`, d1.Marshal())

err = d2.Update(func(root *json.Object, p *presence.Presence) error {
root.GetText("k1").Edit(1, 1, "C")
return nil
})
assert.NoError(t, err)
assert.Equal(t, `{"k1":[{"val":"A"},{"val":"C"},{"val":"B"}]}`, d2.Marshal())

syncClientsThenAssertEqual(t, []clientAndDocPair{{c1, d1}, {c2, d2}})
assert.Equal(t, `{"k1":[{"val":"C"}]}`, d1.Marshal())
})

t.Run("rich text test", func(t *testing.T) {
ctx := context.Background()
d1 := document.New(helper.TestDocKey(t))
Expand Down Expand Up @@ -226,4 +263,45 @@ func TestText(t *testing.T) {
assert.True(t, d1.Root().GetText("k1").CheckWeight())
assert.True(t, d2.Root().GetText("k1").CheckWeight())
})

// Peritext test
t.Run("ex2. concurrent formatting and insertion test", func(t *testing.T) {
ctx := context.Background()
d1 := document.New(helper.TestDocKey(t))
err := c1.Attach(ctx, d1)
assert.NoError(t, err)

err = d1.Update(func(root *json.Object, p *presence.Presence) error {
root.SetNewText("k1").Edit(0, 0, "The fox jumped.", nil)
return nil
})
assert.NoError(t, err)
err = c1.Sync(ctx)
assert.NoError(t, err)

d2 := document.New(helper.TestDocKey(t))
err = c2.Attach(ctx, d2)
assert.NoError(t, err)
assert.Equal(t, `{"k1":[{"val":"The fox jumped."}]}`, d2.Marshal())

err = d1.Update(func(root *json.Object, p *presence.Presence) error {
root.GetText("k1").Style(0, 15, map[string]string{"b": "1"})
return nil
})
assert.NoError(t, err)
assert.Equal(t, `{"k1":[{"attrs":{"b":"1"},"val":"The fox jumped."}]}`, d1.Marshal())

err = d2.Update(func(root *json.Object, p *presence.Presence) error {
root.GetText("k1").Edit(4, 4, "brown ")
return nil
})
assert.NoError(t, err)
assert.Equal(t, `{"k1":[{"val":"The "},{"val":"brown "},{"val":"fox jumped."}]}`, d2.Marshal())

syncClientsThenAssertEqual(t, []clientAndDocPair{{c1, d1}, {c2, d2}})
assert.Equal(t, `{"k1":[{"attrs":{"b":"1"},"val":"The "},{"val":"brown "},{"attrs":{"b":"1"},"val":"fox jumped."}]}`, d1.Marshal())

// TODO(MoonGyu1): d1 and d2 should have the result below after applying mark operation
// assert.Equal(t, `{"k1":[{"attrs":{"b":"1"},"val":"The "},{"attrs":{"b":"1"},"val":"brown "},{"attrs":{"b":"1"},"val":"fox jumped."}]}`, d1.Marshal())
})
}

0 comments on commit 940941a

Please sign in to comment.