Skip to content

Commit 636bd77

Browse files
Abe27342Abram Sanderson
andauthored
[release/2.22] fix(merge-tree): Correctly bookkeep insert into local-only obliterate (#23937)
## Description Resolves some issues in obliterate's insert codepath related to unacked obliterates over the range being inserted into. The gist of the change is that we did not properly bookkeep a scenario where: - The local client has an outstanding obliterate for a range - Multiple other clients also obliterated this range and attempted to insert into it In this case, the local client knows that eventually, the remote clients won't have won the rights to insert into that range (since the local obliterate will remove the segment). However, remote clients won't know this until the local client's obliterate is acked. Therefore if remote clients make subsequent changes without seeing the local client's obliterate, the local client may interpret them incorrectly. The fix here is to fix the bookkeeping of the inserted segment on the local client in the case above, which can be done by being a bit more thorough on examining any overlapping obliterates (specifically, tracking the newest acked obliterate and oldest unacked obliterate separately from the newest overall). See unit tests for more details. [AB#31009](https://dev.azure.com/fluidframework/235294da-091d-4c29-84fc-cdfc3d90890b/_workitems/edit/31009) Cherry-pick of #23934 into 2.22 release branch. Co-authored-by: Abram Sanderson <[email protected]>
1 parent a97b167 commit 636bd77

File tree

2 files changed

+126
-9
lines changed

2 files changed

+126
-9
lines changed

packages/dds/merge-tree/src/mergeTree.ts

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1611,6 +1611,8 @@ export class MergeTree {
16111611
let normalizedNewestSeq: number = 0;
16121612
const movedClientIds: number[] = [];
16131613
const movedSeqs: number[] = [];
1614+
let newestAcked: ObliterateInfo | undefined;
1615+
let oldestUnacked: ObliterateInfo | undefined;
16141616
for (const ob of this.obliterates.findOverlapping(newSegment)) {
16151617
// compute a normalized seq that takes into account local seqs
16161618
// but is still comparable to remote seqs to keep the checks below easy
@@ -1641,6 +1643,23 @@ export class MergeTree {
16411643
normalizedNewestSeq = normalizedObSeq;
16421644
newest = ob;
16431645
}
1646+
1647+
if (
1648+
ob.seq !== UnassignedSequenceNumber &&
1649+
(newestAcked === undefined || newestAcked.seq < ob.seq)
1650+
) {
1651+
newestAcked = ob;
1652+
}
1653+
1654+
if (
1655+
ob.seq === UnassignedSequenceNumber &&
1656+
(oldestUnacked === undefined || oldestUnacked.localSeq! > ob.localSeq!)
1657+
) {
1658+
// There can be one local obliterate surrounding a segment if a client repeatedly obliterates
1659+
// a region (ex: in the text ABCDEFG, obliterate D, then obliterate CE, then BF). In this case,
1660+
// the first one that's applied will be the one that actually removes the segment.
1661+
oldestUnacked = ob;
1662+
}
16441663
}
16451664
}
16461665

@@ -1649,23 +1668,41 @@ export class MergeTree {
16491668
// by the same client that's inserting this segment, we let them insert into this range and therefore don't
16501669
// mark it obliterated.
16511670
if (oldest && newest?.clientId !== clientId) {
1652-
const moveInfo: IMoveInfo = {
1653-
movedClientIds,
1654-
movedSeq: oldest.seq,
1655-
movedSeqs,
1656-
localMovedSeq: oldest.localSeq,
1657-
wasMovedOnInsert: oldest.seq !== UnassignedSequenceNumber,
1658-
};
1671+
let moveInfo: IMoveInfo;
1672+
if (newestAcked === newest || newestAcked?.clientId !== clientId) {
1673+
moveInfo = {
1674+
movedClientIds,
1675+
movedSeq: oldest.seq,
1676+
movedSeqs,
1677+
localMovedSeq: oldestUnacked?.localSeq,
1678+
wasMovedOnInsert: oldest.seq !== UnassignedSequenceNumber,
1679+
};
1680+
} else {
1681+
assert(
1682+
oldestUnacked !== undefined,
1683+
"Expected local obliterate to be defined if newestAcked is not equal to newest",
1684+
);
1685+
// There's a pending local obliterate for this range, so it will be marked as obliterated by us. However,
1686+
// all other clients are under the impression that the most recent acked obliterate won the right to insert
1687+
// in this range.
1688+
moveInfo = {
1689+
movedClientIds: [oldestUnacked.clientId],
1690+
movedSeq: oldestUnacked.seq,
1691+
movedSeqs: [oldestUnacked.seq],
1692+
localMovedSeq: oldestUnacked.localSeq,
1693+
wasMovedOnInsert: false,
1694+
};
1695+
}
16591696

16601697
overwriteInfo(newSegment, moveInfo);
16611698

16621699
if (moveInfo.localMovedSeq !== undefined) {
16631700
assert(
1664-
oldest.segmentGroup !== undefined,
1701+
oldestUnacked?.segmentGroup !== undefined,
16651702
0x86c /* expected segment group to exist */,
16661703
);
16671704

1668-
this.addToPendingList(newSegment, oldest.segmentGroup);
1705+
this.addToPendingList(newSegment, oldestUnacked?.segmentGroup);
16691706
}
16701707

16711708
if (newSegment.parent) {

packages/dds/merge-tree/src/test/obliterate.concurrent.spec.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1911,6 +1911,86 @@ for (const incremental of [true, false]) {
19111911

19121912
helper.logger.validate({ baseText: "8m" });
19131913
});
1914+
1915+
// See 'Local obliterate wins post-insertion of segment previously thought to have won' (below) for a simpler
1916+
// to understand version of this test. This test is the original partial synchronization fuzz variant which
1917+
// demonstrated that issue, and has been preserved for now in case it catches additional related problems.
1918+
// Once fuzz testing more meaninfully leverages ops being sent to different clients at different types (partial
1919+
// synchronization), it's probably fine to remove this.
1920+
it("fuzz regression: Local obliterate wins post-insertion of segment previously thought to have won", () => {
1921+
const helper = new PartialSyncTestHelper();
1922+
1923+
helper.insertText("A", 0, "Hx15J");
1924+
helper.processAllOps();
1925+
helper.insertText("A", 0, "9T");
1926+
helper.insertText("B", 0, "c8v");
1927+
helper.advanceClients("A", "C");
1928+
helper.removeRange("A", 2, 5);
1929+
helper.removeRange("A", 0, 1);
1930+
helper.obliterateRange("A", 0, 3);
1931+
helper.obliterateRange(
1932+
"C",
1933+
{ pos: 1, side: Side.After },
1934+
{ pos: 4, side: Side.Before },
1935+
);
1936+
helper.insertText("B", 0, "4qpo");
1937+
helper.insertText("C", 2, "fP");
1938+
helper.insertText("A", 0, "hn");
1939+
helper.advanceClients("A", "C");
1940+
helper.obliterateRange(
1941+
"A",
1942+
{ pos: 5, side: Side.After },
1943+
{ pos: 9, side: Side.After },
1944+
);
1945+
helper.obliterateRange(
1946+
"B",
1947+
{ pos: 3, side: Side.Before },
1948+
{ pos: 9, side: Side.After },
1949+
);
1950+
// At the time of the original bug, this would hit 0xa3f.
1951+
helper.processAllOps();
1952+
1953+
helper.logger.validate({ baseText: "hn4qpJ" });
1954+
});
1955+
1956+
// Simpler version of the above test which has the same root cause but reproduces a slightly different failure mode.
1957+
it("Local obliterate wins post-insertion of segment previously thought to have won", () => {
1958+
const helper = new PartialSyncTestHelper();
1959+
helper.insertText("A", 0, "1xx2");
1960+
helper.processAllOps();
1961+
// A and B both obliterate the 'xx' segment with an expanding obliterate, then try to insert
1962+
// into the gap that it leaves.
1963+
helper.obliterateRange(
1964+
"A",
1965+
{ pos: 0, side: Side.After },
1966+
{ pos: 3, side: Side.Before },
1967+
);
1968+
helper.insertText("A", 1, "aaaa");
1969+
helper.obliterateRange(
1970+
"B",
1971+
{ pos: 0, side: Side.After },
1972+
{ pos: 3, side: Side.Before },
1973+
);
1974+
helper.insertText("B", 1, "bbb");
1975+
helper.advanceClients("A", "B");
1976+
// Meanwhile, C attempts the same thing without seeing A or B's obliterate & insertions
1977+
helper.obliterateRange(
1978+
"C",
1979+
{ pos: 0, side: Side.After },
1980+
{ pos: 3, side: Side.Before },
1981+
);
1982+
helper.insertText("C", 1, "ccc");
1983+
// Before seeing C's ops, B attempts to insert more content. Since B hasn't yet seen C's obliterate,
1984+
// A and B are under the impression that B's obliterate has won and the string contents are '1bbb2'.
1985+
helper.insertText("B", 5, "B");
1986+
helper.insertText("A", 5, "A");
1987+
helper.processAllOps();
1988+
// By now, all clients realize C actually won the obliterate and should have additionally applied B's
1989+
// op correctly as it was outside of the obliterated range.
1990+
// At the time this test was written, client C had trouble recognizing that A and B think that B has won
1991+
// and merged incorrectly, hitting 'MergeTree insert failed'.
1992+
helper.logger.validate({ baseText: "1ccc2AB" });
1993+
});
19141994
});
19151995
});
19161996
}

0 commit comments

Comments
 (0)