Skip to content

Commit

Permalink
tree: simple-tree optimized unboxing policy (microsoft#22338)
Browse files Browse the repository at this point in the history
## Description

Switch to an unboxing policy which is makes more sense for simple tree's
fix set of leaf types: unbox all leaves.

This should improve performance of access to unions with leaf types.
  • Loading branch information
CraigMacomber authored Sep 3, 2024
1 parent d533e80 commit 34f35b9
Show file tree
Hide file tree
Showing 6 changed files with 40 additions and 159 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import {
FlexFieldSchema,
type FlexTreeNodeSchema,
isLazy,
schemaIsLeaf,
} from "../typed-schema/index.js";
import type { FlexFieldKind } from "../modular-schema/index.js";
import { FieldKinds, type SequenceFieldEditBuilder } from "../default-schema/index.js";
Expand Down Expand Up @@ -394,7 +393,7 @@ class EagerMapTreeOptionalField extends EagerMapTreeField implements FlexTreeOpt
public get content(): FlexTreeUnknownUnboxed | undefined {
const value = this.mapTrees[0];
if (value !== undefined) {
return unboxedUnion(this.schema, value, {
return unboxed(this.schema, value, {
parent: this,
index: 0,
});
Expand Down Expand Up @@ -450,15 +449,15 @@ class EagerMapTreeSequenceField extends EagerMapTreeField implements FlexTreeSeq
if (i === undefined) {
return undefined;
}
return unboxedUnion(this.schema, this.mapTrees[i] ?? oob(), { parent: this, index: i });
return unboxed(this.schema, this.mapTrees[i] ?? oob(), { parent: this, index: i });
}
public map<U>(callbackfn: (value: FlexTreeUnknownUnboxed, index: number) => U): U[] {
return Array.from(this, callbackfn);
}

public *[Symbol.iterator](): IterableIterator<FlexTreeUnknownUnboxed> {
for (const [i, mapTree] of this.mapTrees.entries()) {
yield unboxedUnion(this.schema, mapTree, { parent: this, index: i });
yield unboxed(this.schema, mapTree, { parent: this, index: i });
}
}
}
Expand Down Expand Up @@ -555,21 +554,18 @@ function getOrCreateField(
return new EagerMapTreeField(schema, key, parent);
}

/** Unboxes non-polymorphic leaf nodes to their values, if applicable */
function unboxedUnion(
/** Unboxes leaf nodes to their values */
function unboxed(
schema: FlexFieldSchema,
mapTree: ExclusiveMapTree,
parent: LocationInField,
): FlexTreeUnknownUnboxed {
const type = schema.monomorphicChildType;
if (type !== undefined) {
if (schemaIsLeaf(type)) {
return mapTree.value as FlexTreeUnknownUnboxed;
}
return getOrCreateChild(mapTree, [type], parent) as FlexTreeUnknownUnboxed;
const value = mapTree.value;
if (value !== undefined) {
return value;
}

return getOrCreateChild(mapTree, schema.allowedTypes, parent) as FlexTreeUnknownUnboxed;
return getOrCreateChild(mapTree, schema.allowedTypes, parent);
}

// #endregion Caching and unboxing utilities
Expand Down
79 changes: 4 additions & 75 deletions packages/dds/tree/src/feature-libraries/flex-tree/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ The entry point is `getTreeContext` which provides a root `TreeField` which allo

## Usage modes

There are several main usage modes for this API:
There are two main usage modes for this API:

### Generic tree readers

Expand All @@ -20,27 +20,8 @@ Since these users may not fully understand the semantics of the schema and the i

### Generic tree editors

> **_NOTE:_** This use-case was recently added and is not well supported by the API yet.
> The details of how this should work, and what runtime validation it will do are currently undecided.
This use-case exists to facilitate authoring of wrappers around this API which provide editing functionality, like the proxy based API.

### Schema Aware tree readers and editors

Code that is authored with specific schema in mind, and is statically typed based on those schema, falls into this category.

> **_NOTE:_** This use-case is being de-prioritized.
> It is currently undecided if this use-case will continue to be supported at this API level at all, or it will only be supported in the proxy based API.
> If support for this use-case is removed, major changes and simplifications to will be made.
These users will want to use the typed extensions to the generic untyped API (above).
The `is` methods can be used to [narrow](https://www.typescriptlang.org/docs/handbook/2/narrowing.html) to this typed API when the schema is statically known.
This API surface provides both reading and editing.
Some of its access APIs (for example fields of structs) are `unboxed` meaning that wrappers providing no extra information (to someone who already knows the schema) are discarded or `boxed` meaning that all the layers of the tree (each field and node) are included.
Note that all of the untyped APIs are boxed since generic users may need to inspect the schema of these layers to interpret them.
For schema aware code, usually the unboxed version provide everything that's needed when reading the tree, but when passing data into another API or doing editing, access to these intermediate layers via the `boxed` version can sometimes be necessary.

This could could be split up to separate reading and editing APIs.
This use-case exists to facilitate authoring of wrappers around this API which provide editing functionality, like the simple-based API.
Validation is the responsibility of the caller.

### Subscribing to changes.

Expand All @@ -55,58 +36,6 @@ This means these event subscriptions should only be treated as changes to the co
Some control over these events is available via branching which provides snapshot isolation (among other things):
this is done at a higher level than the tree API covered here but can be accessed via each tree entity's context property. (TODO: provide this access).

## Accommodate multiple usage modes

To accommodate all of these at once in a single API surface, a few principles are followed in the API's design:

- Generic APIs suited for generic (non schema aware) tree readers form the base types of everything in the API.
These provide access to all content of the tree in a uniform manner, including objects for every field and node.
These are all traversable via iteration, and also can be walked along specific paths through the tree.
This for example motivates the include of the optional `.value` on TreeNode.
- Schema aware specializations of these types are provided which provide ergonomic access based on the specific kind of the node or field.
These APIs provide accessors which skip over (or "unbox") redundant layers of the tree - layers that provide no additional information to a user that knows the schema.
Since skipping over these layers is sometimes undesired (for example when needing to edit them), all APIs which do this implicit "unboxing" have "boxed" alternatives that do not skip any layers of the tree.
An example of this is how object nodes provide properties for their fields that "unbox" some field kinds.
For example, reading a Required field returns the content in the field instead of the field itself.
- A subset of the tree properties are picked to be enumerable own properties for use by generic JavaScript object handling code.
This prefers the APIs which do implicit unboxing,
and occasionally special purposes APIs added just for this use-case to ensure all content in the tree is reachable exactly once (for example `MapNode.asObject`).
This ensures operations use object generic traversal like `JSON.stringify` or a simple deep object compare behave semantically correctly (for example they don't lose information which is significant, like types in polymorphic cases).
See Section below for details.
- Special attention is paid to defining what is and is not valid to hold onto across edits, and providing ways to check what edits happened.
For example `Tree.treeStatus` allows checking if the subtree is still part of the document and `TreeNode.on` allows subscribing to events.

## Javascript Object API: `enumerable` and `own` properties

> **_NOTE:_** This use-case is being deprioritized, and the invariants called out below might not hold for future versions of this API.
> Also be aware that the new Proxy based JavaScript object focused API does not meet these invariants since it does not expose types as properties, or a property based way to traverse map nodes.
See [Mozilla's Enumerability and ownership of properties](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Enumerability_and_ownership_of_properties) for context.
Note that `enumerable` has nothing to do with TypeScript's `Iterable` interface or JavaScript's `Symbol.iterator` and the related `for...of` loops: all of those are irrelevant to this section.

Despite this tree API primarily being a TypeScript API, and TypeScript types having no way to indicate if members are `enumerable` or `own`, these details matter, even to TypeScript users.

For example, TypeScript assumes all members of interfaces are `enumerable` and `own` when using the [Object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) operator.
TypeScript also assumes all non-method members of classes are `enumerable` and `own`, but allows assigning the class to an interface where it will start treating the methods as `enumerable` and `own` despite it being the same object.
This compile time behavior often does not match what happens at runtime, so users of TypeScript are likely to run into issues despite their code compiling just fine if they rely on Object spread, or make assumptions about what properties are `enumerable` or `own`.

Many existing libraries users of tree are likely to work with will also make assumptions about `enumerable` or `own`.

So in addition to the TypeScript types, a separate decision needs to be made about what guarantees this library will make about `enumerable` and `own` properties.

This library guarantees that when traversing from a root `UntypedEntity` via `enumerable` `own` properties:

- All callable members are `inherited` (not `own`) or not `enumerable` and thus work like class methods. Note that TypeScript's type checking will get this wrong due to the API using interfaces.
- When starting at a root node or fields, there is exactly one way to traverse to (or past in some unboxed cases) every node and field under it via only `enumerable` `own` properties.
- Every leaf node's value within the tree will be reachable, either from its node, or as its node (in the unboxed case). Note that values are assumed to be immutable, and if multiple leaves hold structurally identical objects as values they may or may not be shared and this difference is not considered significant. TODO: determine how node's assert.deepEqual compares these cases.
- Every node traversed has an unambiguous type, either implied by its position and parent's schema (for unboxed cases) and/or from an `enumerable` `own` property containing the schema's identifier.
- No cycles will be encountered, with the exception of any `FluidHandle` stored as part of serializable values on `LeafNode`s.
- Content outside of the tree, such as its schema objects and context, will not be reachable.
- No symbols will be encountered as keys. This ensures that the traversal can use the APIs which only support strings (like `Object.entries`) and get the same result as if using APIs which also support symbols (like `object spread`).

Note that if using `for...in`, be sure to filter out `inherited` properties.
Using [Object.entries](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/entries) or similar [alternatives that omit inherited and non enumerable properties](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Enumerability_and_ownership_of_properties#traversing_object_properties) will usually be simpler.

## Status

Implemented and working but major changes may happen. See above "**_NOTE:_**"s.
Currently being simplified to reduce and eventually remove the flex tree schema abstraction: all usages at this level should be replaced by stored schema.
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import {
tryMoveCursorToAnchorSymbol,
} from "./lazyEntity.js";
import { type LazyTreeNode, makeTree } from "./lazyNode.js";
import { unboxedUnion } from "./unboxed.js";
import { unboxedFlexNode } from "./unboxed.js";
import { indexForAt, treeStatusFromAnchorCache } from "./utilities.js";
import { UsageError } from "@fluidframework/telemetry-utils/internal";
import { cursorForMapTreeField, cursorForMapTreeNode } from "../mapTreeCursor.js";
Expand Down Expand Up @@ -208,7 +208,7 @@ export abstract class LazyField<out TKind extends FlexFieldKind>

public atIndex(index: number): FlexTreeUnknownUnboxed {
return inCursorNode(this[cursorSymbol], index, (cursor) =>
unboxedUnion(this.context, this.schema, cursor),
unboxedFlexNode(this.context, cursor),
);
}

Expand All @@ -234,7 +234,7 @@ export abstract class LazyField<out TKind extends FlexFieldKind>

public [Symbol.iterator](): IterableIterator<FlexTreeUnknownUnboxed> {
return iterateCursorField(this[cursorSymbol], (cursor) =>
unboxedUnion(this.context, this.schema, cursor),
unboxedFlexNode(this.context, cursor),
);
}

Expand Down Expand Up @@ -284,7 +284,7 @@ export class LazySequence
}

return inCursorNode(this[cursorSymbol], finalIndex, (cursor) =>
unboxedUnion(this.context, this.schema, cursor),
unboxedFlexNode(this.context, cursor),
);
}
public get asArray(): readonly FlexTreeUnknownUnboxed[] {
Expand Down
31 changes: 5 additions & 26 deletions packages/dds/tree/src/feature-libraries/flex-tree/unboxed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,21 @@
*/

import type { ITreeSubscriptionCursor } from "../../core/index.js";
import {
type FlexFieldSchema,
type FlexTreeNodeSchema,
schemaIsLeaf,
} from "../typed-schema/index.js";

import type { Context } from "./context.js";
import type { FlexTreeUnknownUnboxed } from "./flexTreeTypes.js";
import { makeTree } from "./lazyNode.js";

/**
* See {@link FlexTreeUnboxNode} for documentation on what unwrapping this performs.
* Returns the flex tree node, of the value if it has one.
*/
export function unboxedTree(
export function unboxedFlexNode(
context: Context,
schema: FlexTreeNodeSchema,
cursor: ITreeSubscriptionCursor,
): FlexTreeUnknownUnboxed {
if (schemaIsLeaf(schema)) {
return cursor.value as FlexTreeUnknownUnboxed;
}

return makeTree(context, cursor);
}

/**
* See {@link FlexTreeUnboxNodeUnion} for documentation on what unwrapping this performs.
*/
export function unboxedUnion(
context: Context,
schema: FlexFieldSchema,
cursor: ITreeSubscriptionCursor,
): FlexTreeUnknownUnboxed {
const type = schema.monomorphicChildType;
if (type !== undefined) {
return unboxedTree(context, type, cursor);
const value = cursor.value;
if (value !== undefined) {
return value;
}
return makeTree(context, cursor);
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,18 @@ import {
rootFieldKey,
} from "../../../core/index.js";
import type { Context } from "../../../feature-libraries/flex-tree/context.js";
import { unboxedTree, unboxedUnion } from "../../../feature-libraries/flex-tree/unboxed.js";
import type {
FlexAllowedTypes,
FlexFieldKind,
FlexTreeNode,
import { unboxedFlexNode } from "../../../feature-libraries/flex-tree/unboxed.js";
import {
isFlexTreeNode,
type FlexAllowedTypes,
type FlexFieldKind,
} from "../../../feature-libraries/index.js";
import type { TreeContent } from "../../../shared-tree/index.js";

import { contextWithContentReadonly } from "./utils.js";
import { getFlexSchema, SchemaFactory, toFlexSchema } from "../../../simple-tree/index.js";
import { toFlexSchema } from "../../../simple-tree/index.js";
import { stringSchema } from "../../../simple-tree/leafNodeSchema.js";
import { singleJsonCursor } from "../../json/index.js";
import { JsonUnion, singleJsonCursor } from "../../json/index.js";

const rootFieldAnchor: FieldAnchor = { parent: undefined, fieldKey: rootFieldKey };

Expand Down Expand Up @@ -62,7 +62,7 @@ function initializeTreeWithContent<Kind extends FlexFieldKind, Types extends Fle
};
}

describe("unboxedTree", () => {
describe("unboxedFlexNode", () => {
it("Leaf", () => {
const schema = toFlexSchema(stringSchema);

Expand All @@ -72,39 +72,18 @@ describe("unboxedTree", () => {
});
cursor.enterNode(0); // Root node field has 1 node; move into it

assert.equal(unboxedTree(context, getFlexSchema(stringSchema), cursor), "Hello world");
assert.equal(unboxedFlexNode(context, cursor), "Hello world");
});
});

describe("unboxedUnion", () => {
it("Single type", () => {
const builder = new SchemaFactory("test");
const fieldSchema = builder.required(builder.boolean);
const schema = toFlexSchema(fieldSchema);
it("Non-Leaf", () => {
const schema = toFlexSchema(JsonUnion);

const { context, cursor } = initializeTreeWithContent({
schema,
initialTree: singleJsonCursor(false),
});
cursor.enterNode(0); // Root node field has 1 node; move into it

assert.equal(unboxedUnion(context, schema.rootFieldSchema, cursor), false);
});

it("Multi-type", () => {
const builder = new SchemaFactory("test");
const fieldSchema = builder.optional([builder.string, builder.handle]);
const schema = toFlexSchema(fieldSchema);

const { context, cursor } = initializeTreeWithContent({
schema,
initialTree: singleJsonCursor("Hello world"),
initialTree: singleJsonCursor({}),
});
cursor.enterNode(0); // Root node field has 1 node; move into it

// Type is not known based on schema, so node will not be unboxed.
const unboxed = unboxedUnion(context, schema.rootFieldSchema, cursor) as FlexTreeNode;
assert.equal(unboxed.schema, getFlexSchema(stringSchema));
assert.equal(unboxed.value, "Hello world");
assert(isFlexTreeNode(unboxedFlexNode(context, cursor)));
});
});
12 changes: 5 additions & 7 deletions packages/dds/tree/src/test/scalableTestTrees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,18 @@ import {
moveToDetachedField,
rootFieldKey,
} from "../core/index.js";
import { FieldKinds, type FlexTreeNode } from "../feature-libraries/index.js";
import { FieldKinds, isFlexTreeNode, type FlexTreeNode } from "../feature-libraries/index.js";
import type { FlexTreeView, TreeContent } from "../shared-tree/index.js";
import { brand } from "../util/index.js";
import {
cursorFromInsertable,
numberSchema,
SchemaFactory,
type ValidateRecursiveSchema,
} from "../simple-tree/index.js";
// eslint-disable-next-line import/no-internal-modules
import type { TreeStoredContent } from "../shared-tree/schematizeTree.js";
// eslint-disable-next-line import/no-internal-modules
import { getFlexSchema, toFlexSchema, toStoredSchema } from "../simple-tree/toFlexSchema.js";
import { toFlexSchema, toStoredSchema } from "../simple-tree/toFlexSchema.js";

/**
* Test trees which can be parametrically scaled to any size.
Expand Down Expand Up @@ -270,13 +269,12 @@ export function readDeepFlexTree(tree: FlexTreeView): {
} {
let depth = 0;
assert(tree.flexTree.is(FieldKinds.required));
let currentNode = tree.flexTree.content as FlexTreeNode;
while (currentNode.is(getFlexSchema(LinkedList))) {
let currentNode = tree.flexTree.content as FlexTreeNode | number;
while (isFlexTreeNode(currentNode)) {
const read = currentNode.getBoxed(brand("foo"));
assert(read.is(FieldKinds.required));
currentNode = read.content as FlexTreeNode;
depth++;
}
assert(currentNode.is(getFlexSchema(numberSchema)));
return { depth, value: currentNode.value as number };
return { depth, value: currentNode };
}

0 comments on commit 34f35b9

Please sign in to comment.