Skip to content

Commit 93ec6c7

Browse files
fix(tree): commit enrichment after transaction abort (microsoft#25978)
## Description Aborting a transaction used to put the tree in a state that would trigger an assert when sending some undo/redo edits to peers. This would prevent some undo/redo edits from being sent and would put the tree in a broken state that prevented any further edits. This issue could not have caused document corruption, so reopening the document was a possible remedy. After this PR, aborting a transaction no longer puts the tree in such a state, so it is safe to perform undo/redo edits after that. This bug was due to the fact that `TreeCheckout` overwrote its `DetachedFieldIndex` member with a new instance when performing a transaction rollback but had no way of informing the `SharedTreeReadonlyChangeEnricher` that it should now be reading from the new instance during change enrichment. This PR fixes the bug by ensuring that a given `TreeCheckout` instance always uses the same `DetachedFieldIndex` instance. This requires introducing a way of snapshotting and (on transaction rollback) restoring the `DetachedFieldIndex` state that does not require replacing the instance. This PR also reduces the `DetachedFieldIndex` API surface made available to `SharedTreeReadonlyChangeEnricher` by introducing the `ReadOnlyDetachedFieldIndex` interface. This is not required as part of this change and could be done in a separate PR. ## Breaking Changes None ## Reviewer Guidance Let me know if you think it's worth spinning up a separate PR for the introduction of `ReadOnlyDetachedFieldIndex`.
1 parent b0513a6 commit 93ec6c7

File tree

9 files changed

+233
-53
lines changed

9 files changed

+233
-53
lines changed

.changeset/spicy-grapes-leave.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@fluidframework/tree": minor
3+
"fluid-framework": minor
4+
"__section": tree
5+
---
6+
Fixed bug in sending of revert edits after an aborted transaction
7+
8+
Aborting a transaction used to put the tree in a state that would trigger an assert when sending some undo/redo edits to peers.
9+
This would prevent some undo/redo edits from being sent and would put the tree in a broken state that prevented any further edits.
10+
This issue could not have caused document corruption, so reopening the document was a possible remedy.
11+
Aborting a transaction no longer puts the tree in such a state, so it is safe to perform undo/redo edits after that.

packages/dds/tree/src/core/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ export {
8383
CursorMarker,
8484
isCursor,
8585
DetachedFieldIndex,
86+
type ReadOnlyDetachedFieldIndex,
87+
type DetachedFieldIndexCheckpoint,
8688
type ForestRootId,
8789
getDetachedFieldContainingPath,
8890
aboveRootPlaceholder,

packages/dds/tree/src/core/tree/detachedFieldIndex.ts

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,44 @@ import type {
3737
} from "./detachedFieldIndexTypes.js";
3838
import { makeDetachedFieldIndexCodec } from "./detachedFieldIndexCodecs.js";
3939

40+
/**
41+
* Readonly interface for {@link DetachedFieldIndex}.
42+
*/
43+
export interface ReadOnlyDetachedFieldIndex {
44+
/**
45+
* Creates a deep clone of this `DetachedFieldIndex`.
46+
*/
47+
clone(): DetachedFieldIndex;
48+
49+
/**
50+
* Returns a field key for the given ID.
51+
* This does not save the field key on the index. To do so, call {@link createEntry}.
52+
*/
53+
toFieldKey(id: ForestRootId): FieldKey;
54+
55+
/**
56+
* Returns the `ForestRootId` associated with the given id.
57+
* Returns undefined if no such id is known to the index.
58+
*/
59+
tryGetEntry(id: Delta.DetachedNodeId): ForestRootId | undefined;
60+
61+
/**
62+
* Returns the `ForestRootId` associated with the given id.
63+
* Fails if no such id is known to the index.
64+
*/
65+
getEntry(id: Delta.DetachedNodeId): ForestRootId;
66+
}
67+
68+
/**
69+
* Restores the originating DetachedFieldIndex to the state it was in when the checkpoint was created.
70+
* Can be invoked multiple times.
71+
*/
72+
export type DetachedFieldIndexCheckpoint = () => void;
73+
4074
/**
4175
* The tree index records detached field IDs and associates them with a change atom ID.
4276
*/
43-
export class DetachedFieldIndex {
77+
export class DetachedFieldIndex implements ReadOnlyDetachedFieldIndex {
4478
/**
4579
* A mapping from detached node ids to detached fields.
4680
*/
@@ -106,6 +140,25 @@ export class DetachedFieldIndex {
106140
return clone;
107141
}
108142

143+
/**
144+
* Creates a restorable checkpoint of the current state of the DetachedFieldIndex.
145+
*/
146+
public createCheckpoint(): DetachedFieldIndexCheckpoint {
147+
const clone = this.clone();
148+
return () => {
149+
this.purge();
150+
populateNestedMap(clone.detachedNodeToField, this.detachedNodeToField, true);
151+
populateNestedMap(
152+
clone.latestRelevantRevisionToFields,
153+
this.latestRelevantRevisionToFields,
154+
true,
155+
);
156+
this.rootIdAllocator = idAllocatorFromMaxId(
157+
clone.rootIdAllocator.getMaxId(),
158+
) as IdAllocator<ForestRootId>;
159+
};
160+
}
161+
109162
public *entries(): Generator<{
110163
root: ForestRootId;
111164
latestRelevantRevision?: RevisionTag;
@@ -201,26 +254,14 @@ export class DetachedFieldIndex {
201254
}
202255
}
203256

204-
/**
205-
* Returns a field key for the given ID.
206-
* This does not save the field key on the index. To do so, call {@link createEntry}.
207-
*/
208257
public toFieldKey(id: ForestRootId): FieldKey {
209258
return brand(`${this.name}-${id}`);
210259
}
211260

212-
/**
213-
* Returns the FieldKey associated with the given id.
214-
* Returns undefined if no such id is known to the index.
215-
*/
216261
public tryGetEntry(id: Delta.DetachedNodeId): ForestRootId | undefined {
217262
return tryGetFromNestedMap(this.detachedNodeToField, id.major, id.minor)?.root;
218263
}
219264

220-
/**
221-
* Returns the FieldKey associated with the given id.
222-
* Fails if no such id is known to the index.
223-
*/
224265
public getEntry(id: Delta.DetachedNodeId): ForestRootId {
225266
const key = this.tryGetEntry(id);
226267
assert(key !== undefined, 0x7aa /* Unknown removed node ID */);

packages/dds/tree/src/core/tree/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,11 @@ export {
117117
type ChunkedCursor,
118118
} from "./chunk.js";
119119

120-
export { DetachedFieldIndex } from "./detachedFieldIndex.js";
120+
export {
121+
DetachedFieldIndex,
122+
type DetachedFieldIndexCheckpoint,
123+
type ReadOnlyDetachedFieldIndex,
124+
} from "./detachedFieldIndex.js";
121125

122126
export { getCodecTreeForDetachedFieldIndexFormat } from "./detachedFieldIndexCodecs.js";
123127
export { DetachedFieldIndexFormatVersion } from "./detachedFieldIndexFormatCommon.js";

packages/dds/tree/src/shared-tree/sharedTreeChangeEnricher.ts

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
type DeltaDetachedNodeId,
1010
type DetachedFieldIndex,
1111
type IEditableForest,
12+
type IForestSubscription,
13+
type ReadOnlyDetachedFieldIndex,
1214
type RevisionTag,
1315
type TreeStoredSchemaRepository,
1416
tagChange,
@@ -35,24 +37,25 @@ export class SharedTreeReadonlyChangeEnricher
3537
implements ChangeEnricherReadonlyCheckout<SharedTreeChange>
3638
{
3739
/**
38-
* @param forest - The state based on which to enrich changes.
39-
* Exclusively owned by the constructed instance.
40+
* @param borrowedForest - The state based on which to enrich changes.
41+
* Not owned by the constructed instance.
4042
* @param schema - The schema that corresponds to the forest.
41-
* @param removedRoots - The set of removed roots based on which to enrich changes.
42-
* Exclusively owned by the constructed instance.
43+
* @param borrowedRemovedRoots - The set of removed roots based on which to enrich changes.
44+
* Not owned by the constructed instance.
45+
* @param idCompressor - The id compressor to use when chunking trees.
4346
*/
4447
public constructor(
45-
protected readonly forest: IEditableForest,
48+
protected readonly borrowedForest: IForestSubscription,
4649
private readonly schema: TreeStoredSchemaRepository,
47-
protected readonly removedRoots: DetachedFieldIndex,
50+
protected readonly borrowedRemovedRoots: ReadOnlyDetachedFieldIndex,
4851
private readonly idCompressor?: IIdCompressor,
4952
) {}
5053

5154
public fork(): ChangeEnricherMutableCheckout<SharedTreeChange> {
5255
return new SharedTreeMutableChangeEnricher(
53-
this.forest.clone(this.schema, new AnchorSet()),
56+
this.borrowedForest.clone(this.schema, new AnchorSet()),
5457
this.schema,
55-
this.removedRoots.clone(),
58+
this.borrowedRemovedRoots.clone(),
5659
);
5760
}
5861

@@ -66,10 +69,10 @@ export class SharedTreeReadonlyChangeEnricher
6669
}
6770

6871
private readonly getDetachedRoot = (id: DeltaDetachedNodeId): TreeChunk | undefined => {
69-
const root = this.removedRoots.tryGetEntry(id);
72+
const root = this.borrowedRemovedRoots.tryGetEntry(id);
7073
if (root !== undefined) {
71-
const cursor = this.forest.getCursorAboveDetachedFields();
72-
const parentField = this.removedRoots.toFieldKey(root);
74+
const cursor = this.borrowedForest.getCursorAboveDetachedFields();
75+
const parentField = this.borrowedRemovedRoots.toFieldKey(root);
7376
cursor.enterField(parentField);
7477
cursor.enterNode(0);
7578
return chunkTree(cursor, {
@@ -85,6 +88,23 @@ export class SharedTreeMutableChangeEnricher
8588
extends SharedTreeReadonlyChangeEnricher
8689
implements ChangeEnricherMutableCheckout<SharedTreeChange>
8790
{
91+
/**
92+
* @param forest - The state based on which to enrich changes.
93+
* Owned by the constructed instance.
94+
* @param schema - The schema that corresponds to the forest.
95+
* @param removedRoots - The set of removed roots based on which to enrich changes.
96+
* Owned by the constructed instance.
97+
* @param idCompressor - The id compressor to use when chunking trees.
98+
*/
99+
public constructor(
100+
private readonly forest: IEditableForest,
101+
schema: TreeStoredSchemaRepository,
102+
private readonly removedRoots: DetachedFieldIndex,
103+
idCompressor?: IIdCompressor,
104+
) {
105+
super(forest, schema, removedRoots, idCompressor);
106+
}
107+
88108
public applyTipChange(change: SharedTreeChange, revision?: RevisionTag): void {
89109
for (const dataOrSchemaChange of change.changes) {
90110
const type = dataOrSchemaChange.type;

packages/dds/tree/src/shared-tree/treeCheckout.ts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
type TreeNodeStoredSchema,
4949
LeafNodeStoredSchema,
5050
diffHistories,
51+
type ReadOnlyDetachedFieldIndex,
5152
} from "../core/index.js";
5253
import {
5354
type FieldBatchCodec,
@@ -406,7 +407,7 @@ export class TreeCheckout implements ITreeCheckoutFork {
406407
private readonly mintRevisionTag: () => RevisionTag,
407408
private readonly revisionTagCodec: RevisionTagCodec,
408409
private readonly idCompressor: IIdCompressor,
409-
public removedRoots: DetachedFieldIndex = makeDetachedFieldIndex(
410+
private readonly _removedRoots: DetachedFieldIndex = makeDetachedFieldIndex(
410411
"repair",
411412
revisionTagCodec,
412413
idCompressor,
@@ -421,6 +422,10 @@ export class TreeCheckout implements ITreeCheckoutFork {
421422
this.registerForBranchEvents();
422423
}
423424

425+
public get removedRoots(): ReadOnlyDetachedFieldIndex {
426+
return this._removedRoots;
427+
}
428+
424429
private registerForBranchEvents(): void {
425430
this.#transaction.branch.events.on("afterChange", this.onAfterBranchChange);
426431
this.#transaction.activeBranchEvents.on("afterChange", this.onAfterChange);
@@ -441,7 +446,7 @@ export class TreeCheckout implements ITreeCheckoutFork {
441446
(commits) => {
442447
const revision = this.mintRevisionTag();
443448
for (const transactionStep of commits) {
444-
this.removedRoots.updateMajor(transactionStep.revision, revision);
449+
this._removedRoots.updateMajor(transactionStep.revision, revision);
445450
}
446451

447452
const squashedChange = this.changeFamily.rebaser.compose(commits);
@@ -452,12 +457,12 @@ export class TreeCheckout implements ITreeCheckoutFork {
452457
const disposeForks = this.disposeForksAfterTransaction
453458
? trackForksForDisposal(this)
454459
: undefined;
455-
// When each transaction is started, take a snapshot of the current state of removed roots
456-
const removedRootsSnapshot = this.removedRoots.clone();
460+
// When each transaction is started, make a restorable checkpoint of the current state of removed roots
461+
const restoreRemovedRoots = this._removedRoots.createCheckpoint();
457462
return (result) => {
458463
switch (result) {
459464
case TransactionResult.Abort:
460-
this.removedRoots = removedRootsSnapshot;
465+
restoreRemovedRoots();
461466
break;
462467
case TransactionResult.Commit:
463468
if (!this.transaction.isInProgress()) {
@@ -566,7 +571,7 @@ export class TreeCheckout implements ITreeCheckoutFork {
566571
if (innerChange.type === "data") {
567572
const delta = intoDelta(tagChange(innerChange.innerChange, revision));
568573
this.withCombinedVisitor((visitor) => {
569-
visitDelta(delta, visitor, this.removedRoots, revision);
574+
visitDelta(delta, visitor, this._removedRoots, revision);
570575
});
571576
} else if (innerChange.type === "schema") {
572577
// Schema changes from a current to a new schema are expected to be backwards compatible.
@@ -597,14 +602,14 @@ export class TreeCheckout implements ITreeCheckoutFork {
597602
this.withCombinedVisitor((visitor) => {
598603
revisions.forEach((revision) => {
599604
// get all the roots last created or used by the revision
600-
const roots = this.removedRoots.getRootsLastTouchedByRevision(revision);
605+
const roots = this._removedRoots.getRootsLastTouchedByRevision(revision);
601606

602607
// get the detached field for the root and delete it from the removed roots
603608
for (const root of roots) {
604-
visitor.destroy(this.removedRoots.toFieldKey(root), 1);
609+
visitor.destroy(this._removedRoots.toFieldKey(root), 1);
605610
}
606611

607-
this.removedRoots.deleteRootsLastTouchedByRevision(revision);
612+
this._removedRoots.deleteRootsLastTouchedByRevision(revision);
608613
});
609614
});
610615
};
@@ -774,7 +779,7 @@ export class TreeCheckout implements ITreeCheckoutFork {
774779
this.mintRevisionTag,
775780
this.revisionTagCodec,
776781
this.idCompressor,
777-
this.removedRoots.clone(),
782+
this._removedRoots.clone(),
778783
this.logger,
779784
this.breaker,
780785
this.disposeForksAfterTransaction,
@@ -894,8 +899,8 @@ export class TreeCheckout implements ITreeCheckoutFork {
894899
this.assertNoUntrackedRoots();
895900
const trees: [string | number | undefined, number, JsonableTree][] = [];
896901
const cursor = this.forest.allocateCursor("getRemovedRoots");
897-
for (const { id, root } of this.removedRoots.entries()) {
898-
const parentField = this.removedRoots.toFieldKey(root);
902+
for (const { id, root } of this._removedRoots.entries()) {
903+
const parentField = this._removedRoots.toFieldKey(root);
899904
this.forest.moveCursorToPath({ parent: undefined, parentField, parentIndex: 0 }, cursor);
900905
const tree = jsonableTreeFromCursor(cursor);
901906
// This method is used for tree consistency comparison.
@@ -915,7 +920,7 @@ export class TreeCheckout implements ITreeCheckoutFork {
915920
public load(): void {
916921
// Set the tip revision as the latest relevant revision for any removed roots that are loaded from a summary - this allows them to be garbage collected later.
917922
// When a load happens, the head of the trunk and the head of the local/main branch must be the same (this is enforced by SharedTree).
918-
this.removedRoots.setRevisionsForLoadedData(this.#transaction.branch.getHead().revision);
923+
this._removedRoots.setRevisionsForLoadedData(this.#transaction.branch.getHead().revision);
919924
// The content of the checkout (e.g. the forest) has (maybe) changed, so fire an afterBatch event.
920925
this.#events.emit("afterBatch");
921926
}
@@ -987,8 +992,8 @@ export class TreeCheckout implements ITreeCheckoutFork {
987992
private assertNoUntrackedRoots(): void {
988993
const cursor = this.forest.getCursorAboveDetachedFields();
989994
const rootFields = new Set([rootFieldKey]);
990-
for (const { root } of this.removedRoots.entries()) {
991-
rootFields.add(this.removedRoots.toFieldKey(root));
995+
for (const { root } of this._removedRoots.entries()) {
996+
rootFields.add(this._removedRoots.toFieldKey(root));
992997
}
993998

994999
if (!cursor.firstField()) {

0 commit comments

Comments
 (0)