Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor(tree): Use TreeChunk in more places #24001

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
10 changes: 10 additions & 0 deletions packages/dds/tree/src/core/forest/forest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type DetachedField,
type ITreeCursor,
type ITreeCursorSynchronous,
type TreeChunk,
type UpPath,
detachedFieldAsKey,
rootField,
Expand Down Expand Up @@ -82,6 +83,15 @@ export interface IForestSubscription {
*/
clone(schema: TreeStoredSchemaSubscription, anchors: AnchorSet): IEditableForest;

/**
* Generate a TreeChunk for the content in the given field cursor.
* This can be used to chunk data that is then inserted into the forest.
*
* @remarks
* Like {@link chunkField}, but forces the results into a single TreeChunk.
Copy link
Contributor

@CraigMacomber CraigMacomber Mar 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Like {@link chunkField}, but forces the results into a single TreeChunk.
* Like {@link chunkField}, but forces the results into a single TreeChunk.
* While any TreeChunk is compatible with any forest, this method creates one optimized for this specific forest.
* The provided data must be compatible with the forest's current schema.

*/
chunkField(cursor: ITreeCursorSynchronous): TreeChunk;

/**
* Allocates a cursor in the "cleared" state.
* @param source - optional string identifying the source of the cursor for debugging purposes when cursors are not properly cleaned up.
Expand Down
1 change: 0 additions & 1 deletion packages/dds/tree/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ export {
getDetachedFieldContainingPath,
aboveRootPlaceholder,
type DeltaRoot,
type DeltaProtoNode,
type DeltaMark,
type DeltaDetachedNodeId,
type DeltaFieldMap,
Expand Down
4 changes: 2 additions & 2 deletions packages/dds/tree/src/core/tree/anchorSet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ import {
} from "../../util/index.js";
import type { FieldKey } from "../schema-stored/index.js";

import type * as Delta from "./delta.js";
import type { PlaceIndex, Range, UpPath } from "./pathTree.js";
import { EmptyKey } from "./types.js";
import type { DeltaVisitor } from "./visitDelta.js";
import type { ITreeCursorSynchronous } from "./cursor.js";

/**
* A way to refer to a particular tree location within an {@link AnchorSet}.
Expand Down Expand Up @@ -864,7 +864,7 @@ export class AnchorSet implements AnchorLocator {
count,
);
},
create(content: Delta.ProtoNodes, destination: FieldKey): void {
create(content: ITreeCursorSynchronous[], destination: FieldKey): void {
// Nothing to do since content can only be created in a new detached field,
// which cannot contain any anchors.
},
Expand Down
33 changes: 8 additions & 25 deletions packages/dds/tree/src/core/tree/delta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@

import type { RevisionTag } from "../rebase/index.js";
import type { FieldKey } from "../schema-stored/index.js";

import type { ITreeCursorSynchronous } from "./cursor.js";
import type { TreeChunk } from "./chunk.js";

/**
* This format describes changes that must be applied to a forest in order to update it.
Expand Down Expand Up @@ -70,7 +69,7 @@ import type { ITreeCursorSynchronous } from "./cursor.js";
* Represents the change made to a document.
* Immutable, therefore safe to retain for async processing.
*/
export interface Root<TTree = ProtoNode> {
export interface Root<TTrees = ProtoNodes> {
/**
* Changes to apply to the root fields.
*/
Expand All @@ -83,7 +82,7 @@ export interface Root<TTree = ProtoNode> {
* For example, if one wishes to build a tree which is being renamed from ID A to ID B,
* then the build should be listed under ID A.
*/
readonly build?: readonly DetachedNodeBuild<TTree>[];
readonly build?: readonly DetachedNodeBuild<TTrees>[];
/**
* New detached nodes to be destroyed.
* The ordering has no significance.
Expand All @@ -97,7 +96,7 @@ export interface Root<TTree = ProtoNode> {
* Refreshers for detached nodes that may need to be recreated.
* The ordering has no significance.
*/
readonly refreshers?: readonly DetachedNodeBuild<TTree>[];
readonly refreshers?: readonly DetachedNodeBuild<TTrees>[];
/**
* Changes to apply to detached nodes.
* The ordering has no significance.
Expand All @@ -117,25 +116,9 @@ export interface Root<TTree = ProtoNode> {
}

/**
* The default representation for inserted content.
*
* TODO:
* Ownership and lifetime of data referenced by this cursor is unclear,
* so it is a poor abstraction for this use-case which needs to hold onto the data in a non-exclusive (readonly) way.
* Cursors can be one supported way to input data, but aren't a good storage format.
*/
export type ProtoNode = ITreeCursorSynchronous;

/**
* The default representation a chunk (sub-sequence) of inserted content.
*
* TODO:
* See issue TODO with ProtoNode.
* Additionally, Cursors support sequences, so if using cursors, there are better ways to handle this than an array of cursors,
* like using a cursor over all the content (starting in fields mode).
* Long term something like TreeChunk should probably be used here.
* The default representation for a chunk (sub-sequence) of inserted content.
*/
export type ProtoNodes = readonly ProtoNode[];
export type ProtoNodes = TreeChunk;

/**
* Represents a change being made to a part of the document tree.
Expand Down Expand Up @@ -192,9 +175,9 @@ export interface DetachedNodeChanges {
* Tree creation is idempotent: if a tree with the same ID already exists,
* then this build is ignored in favor of the existing tree.
*/
export interface DetachedNodeBuild<TTree = ProtoNode> {
export interface DetachedNodeBuild<TTrees = ProtoNodes> {
readonly id: DetachedNodeId;
readonly trees: readonly TTree[];
readonly trees: TTrees;
}

/**
Expand Down
10 changes: 5 additions & 5 deletions packages/dds/tree/src/core/tree/deltaUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@

import type { Mutable } from "../../util/index.js";
import type { FieldKey } from "../schema-stored/index.js";
import type { TreeChunk } from "./chunk.js";

import type { ITreeCursorSynchronous } from "./cursor.js";
import type { DetachedNodeId, FieldChanges, Mark, Root } from "./delta.js";
import { rootFieldKey } from "./types.js";

export const emptyDelta: Root<never> = {};
export const emptyDelta: Root = {};

export function isAttachMark(mark: Mark): boolean {
return mark.attach !== undefined && mark.detach === undefined;
Expand All @@ -24,15 +24,15 @@ export function isReplaceMark(mark: Mark): boolean {
return mark.detach !== undefined && mark.attach !== undefined;
}

export function deltaForRootInitialization(content: readonly ITreeCursorSynchronous[]): Root {
if (content.length === 0) {
export function deltaForRootInitialization(content: TreeChunk): Root {
if (content.topLevelLength === 0) {
return emptyDelta;
}
const buildId = { minor: 0 };
const delta: Root = {
build: [{ id: buildId, trees: content }],
fields: new Map<FieldKey, FieldChanges>([
[rootFieldKey, [{ count: content.length, attach: buildId }]],
[rootFieldKey, [{ count: content.topLevelLength, attach: buildId }]],
]),
};
return delta;
Expand Down
1 change: 0 additions & 1 deletion packages/dds/tree/src/core/tree/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ export {
export type {
ProtoNodes,
Root as DeltaRoot,
ProtoNode as DeltaProtoNode,
Mark as DeltaMark,
DetachedNodeId as DeltaDetachedNodeId,
FieldMap as DeltaFieldMap,
Expand Down
29 changes: 18 additions & 11 deletions packages/dds/tree/src/core/tree/visitDelta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,8 @@ import { assert } from "@fluidframework/core-utils/internal";
import { type NestedMap, setInNestedMap, tryGetFromNestedMap } from "../../util/index.js";
import type { FieldKey } from "../schema-stored/index.js";

import type { ITreeCursorSynchronous } from "./cursor.js";
// eslint-disable-next-line import/no-duplicates
import { mapCursorField, type ITreeCursorSynchronous } from "./cursor.js";
import type * as Delta from "./delta.js";
// Since ProtoNodes is reexported, import it directly to avoid forcing Delta to be reexported.
// eslint-disable-next-line import/no-duplicates
import type { ProtoNodes } from "./delta.js";
import {
areDetachedNodeIdsEqual,
isAttachMark,
Expand All @@ -24,7 +20,7 @@ import {
import type { DetachedFieldIndex } from "./detachedFieldIndex.js";
import type { ForestRootId, Major, Minor } from "./detachedFieldIndexTypes.js";
import type { NodeIndex, PlaceIndex, Range } from "./pathTree.js";
import type { RevisionTag } from "../index.js";
import type { RevisionTag, TreeChunk } from "../index.js";

/**
* Implementation notes:
Expand Down Expand Up @@ -59,7 +55,7 @@ import type { RevisionTag } from "../index.js";
* This needs to happen last to allow modifications to detached roots to be applied before they are destroyed.
*
* The details of the delta visit algorithm can impact how/when events are emitted by the objects that own the visitors.
* For example, as of 2024-03-27, the subtreecChanged event of an AnchorNode is emitted when exiting a node during a
* For example, as of 2024-03-27, the subtreeChanged event of an AnchorNode is emitted when exiting a node during a
* delta visit, and thus the two-pass nature of the algorithm means the event fires twice for any given change.
* This two-pass nature also means that the event may fire at a time where no change is visible in the tree. E.g.,
* if a node is being replaced, when the event fires during the detach pass no change in the tree has happened so the
Expand Down Expand Up @@ -89,9 +85,10 @@ export function visitDelta(
const rootDestructions: Delta.DetachedNodeDestruction[] = [];
const refreshers: NestedMap<Major, Minor, ITreeCursorSynchronous> = new Map();
delta.refreshers?.forEach(({ id: { major, minor }, trees }) => {
for (let i = 0; i < trees.length; i += 1) {
const treeCursors = nodeCursorsFromChunk(trees);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think either here or on nodeCursorsFromChunk there should probably be a TODO that we should not split up sequences into individual nodes during insert and instead store them as a sequence.

for (let i = 0; i < trees.topLevelLength; i += 1) {
const offsettedId = minor + i;
setInNestedMap(refreshers, major, offsettedId, trees[i]);
setInNestedMap(refreshers, major, offsettedId, treeCursors[i]);
}
});
const detachConfig: PassConfig = {
Expand Down Expand Up @@ -266,7 +263,7 @@ export interface DeltaVisitor {
* @param destination - The key for a new detached field.
* A field with this key must not already exist.
*/
create(content: ProtoNodes, destination: FieldKey): void;
create(content: readonly ITreeCursorSynchronous[], destination: FieldKey): void;
/**
* Recursively destroys the given detached field and all of the nodes within it.
* @param detachedField - The key for the detached field to destroy.
Expand Down Expand Up @@ -476,7 +473,13 @@ function processBuilds(
): void {
if (builds !== undefined) {
for (const { id, trees } of builds) {
buildTrees(id, trees, config.detachedFieldIndex, config.latestRevision, visitor);
buildTrees(
id,
nodeCursorsFromChunk(trees),
config.detachedFieldIndex,
config.latestRevision,
visitor,
);
}
}
}
Expand Down Expand Up @@ -592,3 +595,7 @@ function attachPass(
}
}
}

function nodeCursorsFromChunk(trees: TreeChunk): ITreeCursorSynchronous[] {
return mapCursorField(trees.cursor(), (c) => c.fork());
}
5 changes: 3 additions & 2 deletions packages/dds/tree/src/core/tree/visitorUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import { type IdAllocator, idAllocatorFromMaxId } from "../../util/index.js";
import type { RevisionTag, RevisionTagCodec } from "../rebase/index.js";
import type { FieldKey } from "../schema-stored/index.js";

import type { ProtoNodes, Root } from "./delta.js";
import type { Root } from "./delta.js";
import { DetachedFieldIndex } from "./detachedFieldIndex.js";
import type { ForestRootId } from "./detachedFieldIndexTypes.js";
import type { PlaceIndex, Range } from "./pathTree.js";
import { type DeltaVisitor, visitDelta } from "./visitDelta.js";
import type { IIdCompressor } from "@fluidframework/id-compressor";
import type { ITreeCursorSynchronous } from "./cursor.js";

export function makeDetachedFieldIndex(
prefix: string = "Temp",
Expand Down Expand Up @@ -120,7 +121,7 @@ export interface AnnouncedVisitor extends DeltaVisitor {
/**
* A hook that is called after all nodes have been created.
*/
afterCreate(content: ProtoNodes, destination: FieldKey): void;
afterCreate(content: readonly ITreeCursorSynchronous[], destination: FieldKey): void;
beforeDestroy(field: FieldKey, count: number): void;
beforeAttach(source: FieldKey, count: number, destination: PlaceIndex): void;
afterAttach(source: FieldKey, destination: Range): void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@ export function chunkField(
policy: ChunkCompressor,
): TreeChunk[] {
const length = cursor.getFieldLength();
if (length === 0) {
return [];
}
Comment on lines +177 to +179
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is interesting. chunkRange would actually handle this case correctly if the assert in it were removed.

Changing the assert in chunkRange to be:
assert((length === 0 && !skipLastNavigation) === (cursor.mode === CursorLocationType.Nodes), "should be in nodes mode or past end");

seems like a better fix to me.

It would be good to at least add a regression test for chunkField(emptyChunk.cursor(), someCompressor) to src/test/feature-libraries/chunked-forest/chunkTree.spec.ts

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assert triggers when length is not zero and cursor.mode === CursorLocationType.Nodes is true. Maybe I misunderstood what changes you wanted me to make. Do you want me to remove the call to cursor.firstNode(); from chunkField?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got that assert slightly wrong. Working around JS not having XOR by using ===, I need to negate one of the sides....

I made #24009 which cleans this up based on your changes and test.

const started = cursor.firstNode();
assert(started, 0x57c /* field to chunk should have at least one node */);
return chunkRange(cursor, policy, length, false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import {
type ITreeSubscriptionCursor,
ITreeSubscriptionCursorState,
type PlaceIndex,
type ProtoNodes,
type Range,
TreeNavigationResult,
type TreeStoredSchemaSubscription,
Expand All @@ -44,7 +43,7 @@ import {
} from "../../util/index.js";

import { BasicChunk, BasicChunkCursor, type SiblingsOrKey } from "./basicChunk.js";
import { type IChunker, basicChunkTree, chunkTree } from "./chunkTree.js";
import { type IChunker, basicChunkTree, chunkFieldSingle, chunkTree } from "./chunkTree.js";
import type { IIdCompressor } from "@fluidframework/id-compressor";

function makeRoot(): BasicChunk {
Expand Down Expand Up @@ -91,6 +90,10 @@ export class ChunkedForest implements IEditableForest {
return new ChunkedForest(this.roots, schema, this.chunker.clone(schema), anchors);
}

public chunkField(cursor: ITreeCursorSynchronous): TreeChunk {
return chunkFieldSingle(cursor, { idCompressor: this.idCompressor, policy: this.chunker });
}

public forgetAnchor(anchor: Anchor): void {
this.anchors.forget(anchor);
}
Expand Down Expand Up @@ -136,7 +139,7 @@ export class ChunkedForest implements IEditableForest {
this.forest.#events.emit("beforeChange");
this.forest.roots.fields.delete(detachedField);
},
create(content: ProtoNodes, destination: FieldKey): void {
create(content: readonly ITreeCursorSynchronous[], destination: FieldKey): void {
this.forest.#events.emit("beforeChange");
const chunks: TreeChunk[] = content.map((c) =>
chunkTree(c, {
Expand Down
Loading
Loading