Skip to content

Commit 1594345

Browse files
authored
Support concurrent formatting of Text (#642)
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.
1 parent 37c332f commit 1594345

File tree

9 files changed

+146
-13
lines changed

9 files changed

+146
-13
lines changed

src/api/converter.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,10 @@ function toOperation(operation: Operation): PbOperation {
357357
);
358358
pbStyleOperation.setFrom(toTextNodePos(styleOperation.getFromPos()));
359359
pbStyleOperation.setTo(toTextNodePos(styleOperation.getToPos()));
360+
const pbCreatedAtMapByActor = pbStyleOperation.getCreatedAtMapByActorMap();
361+
for (const [key, value] of styleOperation.getMaxCreatedAtMapByActor()) {
362+
pbCreatedAtMapByActor.set(key, toTimeTicket(value)!);
363+
}
360364
const pbAttributes = pbStyleOperation.getAttributesMap();
361365
for (const [key, value] of styleOperation.getAttributes()) {
362366
pbAttributes.set(key, value);
@@ -1073,6 +1077,10 @@ function fromOperations(pbOperations: Array<PbOperation>): Array<Operation> {
10731077
);
10741078
} else if (pbOperation.hasStyle()) {
10751079
const pbStyleOperation = pbOperation.getStyle();
1080+
const createdAtMapByActor = new Map();
1081+
pbStyleOperation!.getCreatedAtMapByActorMap().forEach((value, key) => {
1082+
createdAtMapByActor.set(key, fromTimeTicket(value));
1083+
});
10761084
const attributes = new Map();
10771085
pbStyleOperation!.getAttributesMap().forEach((value, key) => {
10781086
attributes.set(key, value);
@@ -1081,6 +1089,7 @@ function fromOperations(pbOperations: Array<PbOperation>): Array<Operation> {
10811089
fromTimeTicket(pbStyleOperation!.getParentCreatedAt())!,
10821090
fromTextNodePos(pbStyleOperation!.getFrom()!),
10831091
fromTextNodePos(pbStyleOperation!.getTo()!),
1092+
createdAtMapByActor,
10841093
attributes,
10851094
fromTimeTicket(pbStyleOperation!.getExecutedAt())!,
10861095
);

src/api/yorkie/v1/resources.proto

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ message Operation {
111111
TextNodePos to = 3;
112112
map<string, string> attributes = 4;
113113
TimeTicket executed_at = 5;
114+
map<string, TimeTicket> created_at_map_by_actor = 6;
114115
}
115116
message Increase {
116117
TimeTicket parent_created_at = 1;

src/api/yorkie/v1/resources_pb.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,9 @@ export namespace Operation {
479479
hasExecutedAt(): boolean;
480480
clearExecutedAt(): Style;
481481

482+
getCreatedAtMapByActorMap(): jspb.Map<string, TimeTicket>;
483+
clearCreatedAtMapByActorMap(): Style;
484+
482485
serializeBinary(): Uint8Array;
483486
toObject(includeInstance?: boolean): Style.AsObject;
484487
static toObject(includeInstance: boolean, msg: Style): Style.AsObject;
@@ -494,6 +497,7 @@ export namespace Operation {
494497
to?: TextNodePos.AsObject,
495498
attributesMap: Array<[string, string]>,
496499
executedAt?: TimeTicket.AsObject,
500+
createdAtMapByActorMap: Array<[string, TimeTicket.AsObject]>,
497501
}
498502
}
499503

src/api/yorkie/v1/resources_pb.js

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4224,7 +4224,8 @@ proto.yorkie.v1.Operation.Style.toObject = function(includeInstance, msg) {
42244224
from: (f = msg.getFrom()) && proto.yorkie.v1.TextNodePos.toObject(includeInstance, f),
42254225
to: (f = msg.getTo()) && proto.yorkie.v1.TextNodePos.toObject(includeInstance, f),
42264226
attributesMap: (f = msg.getAttributesMap()) ? f.toObject(includeInstance, undefined) : [],
4227-
executedAt: (f = msg.getExecutedAt()) && proto.yorkie.v1.TimeTicket.toObject(includeInstance, f)
4227+
executedAt: (f = msg.getExecutedAt()) && proto.yorkie.v1.TimeTicket.toObject(includeInstance, f),
4228+
createdAtMapByActorMap: (f = msg.getCreatedAtMapByActorMap()) ? f.toObject(includeInstance, proto.yorkie.v1.TimeTicket.toObject) : []
42284229
};
42294230

42304231
if (includeInstance) {
@@ -4287,6 +4288,12 @@ proto.yorkie.v1.Operation.Style.deserializeBinaryFromReader = function(msg, read
42874288
reader.readMessage(value,proto.yorkie.v1.TimeTicket.deserializeBinaryFromReader);
42884289
msg.setExecutedAt(value);
42894290
break;
4291+
case 6:
4292+
var value = msg.getCreatedAtMapByActorMap();
4293+
reader.readMessage(value, function(message, reader) {
4294+
jspb.Map.deserializeBinary(message, reader, jspb.BinaryReader.prototype.readString, jspb.BinaryReader.prototype.readMessage, proto.yorkie.v1.TimeTicket.deserializeBinaryFromReader, "", new proto.yorkie.v1.TimeTicket());
4295+
});
4296+
break;
42904297
default:
42914298
reader.skipField();
42924299
break;
@@ -4352,6 +4359,10 @@ proto.yorkie.v1.Operation.Style.serializeBinaryToWriter = function(message, writ
43524359
proto.yorkie.v1.TimeTicket.serializeBinaryToWriter
43534360
);
43544361
}
4362+
f = message.getCreatedAtMapByActorMap(true);
4363+
if (f && f.getLength() > 0) {
4364+
f.serializeBinary(6, writer, jspb.BinaryWriter.prototype.writeString, jspb.BinaryWriter.prototype.writeMessage, proto.yorkie.v1.TimeTicket.serializeBinaryToWriter);
4365+
}
43554366
};
43564367

43574368

@@ -4525,6 +4536,28 @@ proto.yorkie.v1.Operation.Style.prototype.hasExecutedAt = function() {
45254536
};
45264537

45274538

4539+
/**
4540+
* map<string, TimeTicket> created_at_map_by_actor = 6;
4541+
* @param {boolean=} opt_noLazyCreate Do not create the map if
4542+
* empty, instead returning `undefined`
4543+
* @return {!jspb.Map<string,!proto.yorkie.v1.TimeTicket>}
4544+
*/
4545+
proto.yorkie.v1.Operation.Style.prototype.getCreatedAtMapByActorMap = function(opt_noLazyCreate) {
4546+
return /** @type {!jspb.Map<string,!proto.yorkie.v1.TimeTicket>} */ (
4547+
jspb.Message.getMapField(this, 6, opt_noLazyCreate,
4548+
proto.yorkie.v1.TimeTicket));
4549+
};
4550+
4551+
4552+
/**
4553+
* Clears values from the map. The map will be non-null.
4554+
* @return {!proto.yorkie.v1.Operation.Style} returns this
4555+
*/
4556+
proto.yorkie.v1.Operation.Style.prototype.clearCreatedAtMapByActorMap = function() {
4557+
this.getCreatedAtMapByActorMap().clear();
4558+
return this;};
4559+
4560+
45284561

45294562

45304563

src/document/crdt/rga_tree_split.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,16 @@ export class RGATreeSplitNode<
444444
);
445445
}
446446

447+
/**
448+
* `canStyle` checks if node is able to set style.
449+
*/
450+
public canStyle(editedAt: TimeTicket, latestCreatedAt: TimeTicket): boolean {
451+
return (
452+
!this.getCreatedAt().after(latestCreatedAt) &&
453+
(!this.removedAt || editedAt.after(this.removedAt))
454+
);
455+
}
456+
447457
/**
448458
* `remove` removes node of given edited time.
449459
*/

src/document/crdt/text.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,17 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { TimeTicket } from '@yorkie-js-sdk/src/document/time/ticket';
17+
import {
18+
InitialTimeTicket,
19+
MaxTimeTicket,
20+
TimeTicket,
21+
} from '@yorkie-js-sdk/src/document/time/ticket';
1822
import { Indexable } from '@yorkie-js-sdk/src/document/document';
1923
import { RHT } from '@yorkie-js-sdk/src/document/crdt/rht';
2024
import { CRDTGCElement } from '@yorkie-js-sdk/src/document/crdt/element';
2125
import {
2226
RGATreeSplit,
27+
RGATreeSplitNode,
2328
RGATreeSplitPosRange,
2429
ValueChange,
2530
} from '@yorkie-js-sdk/src/document/crdt/rga_tree_split';
@@ -233,7 +238,8 @@ export class CRDTText<A extends Indexable = Indexable> extends CRDTGCElement {
233238
range: RGATreeSplitPosRange,
234239
attributes: Record<string, string>,
235240
editedAt: TimeTicket,
236-
): Array<TextChange<A>> {
241+
latestCreatedAtMapByActor?: Map<string, TimeTicket>,
242+
): [Map<string, TimeTicket>, Array<TextChange<A>>] {
237243
// 01. split nodes with from and to
238244
const [, toRight] = this.rgaTreeSplit.findNodeWithSplit(range[1], editedAt);
239245
const [, fromRight] = this.rgaTreeSplit.findNodeWithSplit(
@@ -244,7 +250,29 @@ export class CRDTText<A extends Indexable = Indexable> extends CRDTGCElement {
244250
// 02. style nodes between from and to
245251
const changes: Array<TextChange<A>> = [];
246252
const nodes = this.rgaTreeSplit.findBetween(fromRight, toRight);
253+
const createdAtMapByActor = new Map<string, TimeTicket>();
254+
const toBeStyleds: Array<RGATreeSplitNode<CRDTTextValue>> = [];
255+
247256
for (const node of nodes) {
257+
const actorID = node.getCreatedAt().getActorID()!;
258+
259+
const latestCreatedAt = latestCreatedAtMapByActor?.size
260+
? latestCreatedAtMapByActor!.has(actorID!)
261+
? latestCreatedAtMapByActor!.get(actorID!)!
262+
: InitialTimeTicket
263+
: MaxTimeTicket;
264+
265+
if (node.canStyle(editedAt, latestCreatedAt)) {
266+
const latestCreatedAt = createdAtMapByActor.get(actorID);
267+
const createdAt = node.getCreatedAt();
268+
if (!latestCreatedAt || createdAt.after(latestCreatedAt)) {
269+
createdAtMapByActor.set(actorID, createdAt);
270+
}
271+
toBeStyleds.push(node);
272+
}
273+
}
274+
275+
for (const node of toBeStyleds) {
248276
if (node.isRemoved()) {
249277
continue;
250278
}
@@ -267,7 +295,7 @@ export class CRDTText<A extends Indexable = Indexable> extends CRDTGCElement {
267295
}
268296
}
269297

270-
return changes;
298+
return [createdAtMapByActor, changes];
271299
}
272300

273301
/**

src/document/json/text.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,13 +165,14 @@ export class Text<A extends Indexable = Indexable> {
165165

166166
const attrs = stringifyObjectValues(attributes);
167167
const ticket = this.context.issueTimeTicket();
168-
this.text.setStyle(range, attrs, ticket);
168+
const [maxCreatedAtMapByActor] = this.text.setStyle(range, attrs, ticket);
169169

170170
this.context.push(
171171
new StyleOperation(
172172
this.text.getCreatedAt(),
173173
range[0],
174174
range[1],
175+
maxCreatedAtMapByActor,
175176
new Map(Object.entries(attrs)),
176177
ticket,
177178
),

src/document/operation/style_operation.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,21 @@ import { Indexable } from '../document';
3131
export class StyleOperation extends Operation {
3232
private fromPos: RGATreeSplitPos;
3333
private toPos: RGATreeSplitPos;
34+
private maxCreatedAtMapByActor: Map<string, TimeTicket>;
3435
private attributes: Map<string, string>;
3536

3637
constructor(
3738
parentCreatedAt: TimeTicket,
3839
fromPos: RGATreeSplitPos,
3940
toPos: RGATreeSplitPos,
41+
maxCreatedAtMapByActor: Map<string, TimeTicket>,
4042
attributes: Map<string, string>,
4143
executedAt: TimeTicket,
4244
) {
4345
super(parentCreatedAt, executedAt);
4446
this.fromPos = fromPos;
4547
this.toPos = toPos;
48+
this.maxCreatedAtMapByActor = maxCreatedAtMapByActor;
4649
this.attributes = attributes;
4750
}
4851

@@ -53,13 +56,15 @@ export class StyleOperation extends Operation {
5356
parentCreatedAt: TimeTicket,
5457
fromPos: RGATreeSplitPos,
5558
toPos: RGATreeSplitPos,
59+
maxCreatedAtMapByActor: Map<string, TimeTicket>,
5660
attributes: Map<string, string>,
5761
executedAt: TimeTicket,
5862
): StyleOperation {
5963
return new StyleOperation(
6064
parentCreatedAt,
6165
fromPos,
6266
toPos,
67+
maxCreatedAtMapByActor,
6368
attributes,
6469
executedAt,
6570
);
@@ -77,10 +82,11 @@ export class StyleOperation extends Operation {
7782
logger.fatal(`fail to execute, only Text can execute edit`);
7883
}
7984
const text = parentObject as CRDTText<A>;
80-
const changes = text.setStyle(
85+
const [, changes] = text.setStyle(
8186
[this.fromPos, this.toPos],
8287
this.attributes ? Object.fromEntries(this.attributes) : {},
8388
this.getExecutedAt(),
89+
this.maxCreatedAtMapByActor,
8490
);
8591
return changes.map(({ from, to, value }) => {
8692
return {
@@ -131,4 +137,12 @@ export class StyleOperation extends Operation {
131137
public getAttributes(): Map<string, string> {
132138
return this.attributes;
133139
}
140+
141+
/**
142+
* `getMaxCreatedAtMapByActor` returns the map that stores the latest creation time
143+
* by actor for the nodes included in the editing range.
144+
*/
145+
public getMaxCreatedAtMapByActor(): Map<string, TimeTicket> {
146+
return this.maxCreatedAtMapByActor;
147+
}
134148
}

test/integration/text_test.ts

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,38 @@ describe('Text', function () {
297297
}, this.test!.title);
298298
});
299299

300+
it('should handle concurrent insertion and deletion', async function () {
301+
await withTwoClientsAndDocuments<{ k1: Text }>(async (c1, d1, c2, d2) => {
302+
d1.update((root) => {
303+
root.k1 = new Text();
304+
root.k1.edit(0, 0, 'AB');
305+
}, 'set new text by c1');
306+
await c1.sync();
307+
await c2.sync();
308+
assert.equal(d1.toSortedJSON(), `{"k1":[{"val":"AB"}]}`);
309+
assert.equal(d1.toSortedJSON(), d2.toSortedJSON());
310+
311+
d1.update((root) => {
312+
root['k1'].edit(0, 2, '');
313+
});
314+
assert.equal(d1.toSortedJSON(), `{"k1":[]}`);
315+
d2.update((root) => {
316+
root['k1'].edit(1, 1, 'C');
317+
});
318+
assert.equal(
319+
d2.toSortedJSON(),
320+
`{"k1":[{"val":"A"},{"val":"C"},{"val":"B"}]}`,
321+
);
322+
323+
await c1.sync();
324+
await c2.sync();
325+
await c1.sync();
326+
assert.equal(d1.toSortedJSON(), `{"k1":[{"val":"C"}]}`);
327+
assert.equal(d2.toSortedJSON(), `{"k1":[{"val":"C"}]}`);
328+
assert.equal(d1.toSortedJSON(), d2.toSortedJSON());
329+
}, this.test!.title);
330+
});
331+
300332
it('should handle concurrent block deletions', async function () {
301333
await withTwoClientsAndDocuments<{ k1: Text }>(async (c1, d1, c2, d2) => {
302334
d1.update((root) => {
@@ -412,7 +444,7 @@ describe('peri-text example: text concurrent edit', function () {
412444
}, this.test!.title);
413445
});
414446

415-
it.skip('ex2. concurrent formatting and insertion', async function () {
447+
it('ex2. concurrent formatting and insertion', async function () {
416448
await withTwoClientsAndDocuments<{ k1: Text }>(async (c1, d1, c2, d2) => {
417449
d1.update((root) => {
418450
root.k1 = new Text();
@@ -440,17 +472,18 @@ describe('peri-text example: text concurrent edit', function () {
440472
await c1.sync();
441473
await c2.sync();
442474
await c1.sync();
443-
// NOTE(chacha912): d1 and d2 should have the same content
444475
assert.equal(
445476
d1.toSortedJSON(),
446477
'{"k1":[{"attrs":{"bold":true},"val":"The "},{"val":"brown "},{"attrs":{"bold":true},"val":"fox jumped."}]}',
447478
'd1',
448479
);
449-
assert.equal(
450-
d2.toSortedJSON(),
451-
'{"k1":[{"attrs":{"bold":true},"val":"The "},{"attrs":{"bold":true},"val":"brown "},{"attrs":{"bold":true},"val":"fox jumped."}]}',
452-
'd2',
453-
);
480+
// TODO(MoonGyu1): d1 and d2 should have the result below after applying mark operation
481+
// assert.equal(
482+
// d1.toSortedJSON(),
483+
// '{"k1":[{"attrs":{"bold":true},"val":"The "},{"attrs":{"bold":true},"val":"brown "},{"attrs":{"bold":true},"val":"fox jumped."}]}',
484+
// 'd1',
485+
// );
486+
assert.equal(d2.toSortedJSON(), d1.toSortedJSON());
454487
}, this.test!.title);
455488
});
456489

0 commit comments

Comments
 (0)