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

Enable Synchronous Child Datastore Creation #22962

Open
wants to merge 27 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5927f19
sync datastore creation prototype
anthony-murphy Oct 25, 2024
9125154
revert
anthony-murphy Oct 28, 2024
2769375
Merge branch 'main' of https://github.com/microsoft/FluidFramework in…
anthony-murphy Oct 28, 2024
7fc16d8
refactor the test a bit
anthony-murphy Oct 28, 2024
644e1fb
add comments
anthony-murphy Oct 28, 2024
4e933a0
Merge branch 'main' of https://github.com/microsoft/FluidFramework in…
anthony-murphy Oct 28, 2024
c5b3581
more comments
anthony-murphy Oct 28, 2024
67a245d
Merge branch 'main' into prototype-sync-ds-create
anthony-murphy Oct 29, 2024
08cb956
fix policy-check errors
anthony-murphy Oct 29, 2024
85369a1
Merge branch 'main' of https://github.com/microsoft/FluidFramework in…
anthony-murphy Oct 29, 2024
2df41d3
synchronous creation changes
anthony-murphy Oct 29, 2024
e569174
Merge branch 'main' of https://github.com/microsoft/FluidFramework in…
anthony-murphy Oct 30, 2024
42ffd71
Merge branch 'main' of https://github.com/microsoft/FluidFramework in…
anthony-murphy Nov 1, 2024
e19241d
clean up sync path
anthony-murphy Nov 1, 2024
bb44f76
allow synchronous registry entries
anthony-murphy Nov 1, 2024
33a08d7
add unit tests
anthony-murphy Nov 1, 2024
24a7d4d
add more doc comments
anthony-murphy Nov 1, 2024
908b467
improve e2e test
anthony-murphy Nov 1, 2024
d6ad348
reverts
anthony-murphy Nov 1, 2024
226fd1d
fix NamedFluidDataStoreRegistryEntry
anthony-murphy Nov 1, 2024
810fc0c
remove old comments
anthony-murphy Nov 2, 2024
cde8b76
Merge branch 'main' of https://github.com/microsoft/FluidFramework in…
anthony-murphy Nov 5, 2024
f9d671a
add some comments
anthony-murphy Nov 5, 2024
da3361e
improve comments and testing
anthony-murphy Nov 5, 2024
fb46878
add another example
anthony-murphy Nov 5, 2024
50325b2
update docs
anthony-murphy Nov 5, 2024
ee11048
Update packages/runtime/runtime-definitions/src/dataStoreFactory.ts
anthony-murphy Nov 5, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion packages/runtime/container-runtime/src/dataStoreContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ import {
type IEvent,
} from "@fluidframework/core-interfaces";
import { type IFluidHandleInternal } from "@fluidframework/core-interfaces/internal";
import { assert, LazyPromise, unreachableCase } from "@fluidframework/core-utils/internal";
import {
assert,
isPromiseLike,
LazyPromise,
unreachableCase,
} from "@fluidframework/core-utils/internal";
import { IClientDetails, IQuorumClients } from "@fluidframework/driver-definitions";
import {
IDocumentStorageService,
Expand Down Expand Up @@ -55,6 +60,7 @@ import {
IInboundSignalMessage,
type IPendingMessagesState,
type IRuntimeMessageCollection,
type IFluidDataStoreFactory,
} from "@fluidframework/runtime-definitions/internal";
import {
addBlobToSummary,
Expand All @@ -65,6 +71,7 @@ import {
LoggingError,
MonitoringContext,
ThresholdCounter,
UsageError,
createChildMonitoringContext,
extractSafePropertiesFromMessage,
generateStack,
Expand Down Expand Up @@ -523,6 +530,43 @@ export abstract class FluidDataStoreContext
return factory;
}

createChildDataStoreSync<T extends IFluidDataStoreFactory>(
Copy link
Contributor

Choose a reason for hiding this comment

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

What's the reasoning for:

  1. Putting this on FluidDataStoreContext instead of ContainerRuntime, given that ContainerRuntime currently has the async createDataStore?
  2. Requiring it to be a child?

Copy link
Contributor Author

@anthony-murphy anthony-murphy Nov 1, 2024

Choose a reason for hiding this comment

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

Only the context has access to its factory synchronously, as its factory must be loaded before it is realized. This is basically the same pattern we use for dds.

childFactory: T,
): ReturnType<Exclude<T["createDataStore"], undefined>> {
const maybe = this.registry?.get(childFactory.type);

const isUndefined = maybe === undefined;
const isPromise = isPromiseLike(maybe);
const diffInstance = isPromise || maybe?.IFluidDataStoreFactory !== childFactory;

if (isUndefined || isPromise || diffInstance) {
throw new UsageError(
"The provided factory instance must be synchronously available as a child of this datastore",
{ isUndefined, isPromise, diffInstance },
);
}
if (childFactory?.createDataStore === undefined) {
throw new UsageError("createDataStore must exist on the provided factory", {
noCreateDataStore: true,
});
}

const context = this._containerRuntime.createDetachedDataStore([
...this.packagePath,
childFactory.type,
]);
assert(
context instanceof LocalDetachedFluidDataStoreContext,
"must be a LocalDetachedFluidDataStoreContext",
);

const created = childFactory.createDataStore(context) as ReturnType<
Exclude<T["createDataStore"], undefined>
>;
context.unsafe_AttachRuntimeSync(created.runtime);
return created;
}

private async realizeCore(existing: boolean) {
const details = await this.getInitialSnapshotDetails();
// Base snapshot is the baseline where pending ops are applied to.
Expand Down Expand Up @@ -1428,6 +1472,24 @@ export class LocalDetachedFluidDataStoreContext
return this.channelToDataStoreFn(await this.channelP);
}

/**
* This method provides a synchronous path for binding a runtime to the context.
*
* Due to its synchronous nature, it is unable to validate that the runtime
* represents a datastore which is instantiable by remote clients. This could
* happen if the runtime's package path does not return a factory when looked up
* in the container runtime's registry, or if the runtime's entrypoint is not
* properly initialized. As both of these validation's are asynchronous to preform.
*
* If used incorrectly, this function can result in permanent data corruption.
*/
public unsafe_AttachRuntimeSync(channel: IFluidDataStoreChannel) {
anthony-murphy marked this conversation as resolved.
Show resolved Hide resolved
this.channelP = Promise.resolve(channel);
this.processPendingOps(channel);
this.completeBindingRuntime(channel);
return this.channelToDataStoreFn(channel);
}

public async getInitialSnapshotDetails(): Promise<ISnapshotDetails> {
if (this.detachedRuntimeCreation) {
throw new Error(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/*!
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
* Licensed under the MIT License.
*/

import { strict as assert } from "assert";

import { FluidErrorTypes } from "@fluidframework/core-interfaces/internal";
import { LazyPromise } from "@fluidframework/core-utils/internal";
import { IDocumentStorageService } from "@fluidframework/driver-definitions/internal";
import {
IFluidDataStoreChannel,
IFluidDataStoreFactory,
IFluidDataStoreRegistry,
IFluidParentContext,
type NamedFluidDataStoreRegistryEntries,
type IContainerRuntimeBase,
type ISummarizerNodeWithGC,
} from "@fluidframework/runtime-definitions/internal";
import { isFluidError } from "@fluidframework/telemetry-utils/internal";
import { MockFluidDataStoreRuntime } from "@fluidframework/test-runtime-utils/internal";

import {
FluidDataStoreContext,
LocalDetachedFluidDataStoreContext,
} from "../dataStoreContext.js";

describe("createChildDataStoreSync", () => {
const throwNYI = () => {
throw new Error("Method not implemented.");
};
const testContext = class TestContext extends FluidDataStoreContext {
protected pkg = ["ParentDataStore"];
public registry: IFluidDataStoreRegistry | undefined;
public getInitialSnapshotDetails = throwNYI;
public setAttachState = throwNYI;
public getAttachSummary = throwNYI;
public getAttachGCData = throwNYI;
protected channel = new Proxy({} as any as IFluidDataStoreChannel, { get: throwNYI });
protected channelP = new LazyPromise(async () => this.channel);
};

const createRegistry = (
namedEntries?: NamedFluidDataStoreRegistryEntries,
): IFluidDataStoreRegistry => ({
get IFluidDataStoreRegistry() {
return this;
},
// eslint-disable-next-line @typescript-eslint/promise-function-async
get(name) {
return new Map(namedEntries).get(name);
},
});

const createContext = (namedEntries?: NamedFluidDataStoreRegistryEntries) => {
const registry = createRegistry(namedEntries);
const createSummarizerNodeFn = () =>
new Proxy({} as any as ISummarizerNodeWithGC, { get: throwNYI });
const storage = new Proxy({} as any as IDocumentStorageService, { get: throwNYI });

const parentContext = {
clientDetails: {
capabilities: { interactive: true },
},
containerRuntime: {
createDetachedDataStore(pkg, loadingGroupId) {
return new LocalDetachedFluidDataStoreContext({
channelToDataStoreFn: (channel) => ({
entryPoint: channel.entryPoint,
trySetAlias: throwNYI,
}),
createSummarizerNodeFn,
id: "child",
makeLocallyVisibleFn: throwNYI,
parentContext,
pkg,
scope: {},
snapshotTree: undefined,
storage,
loadingGroupId,
});
},
} satisfies Partial<IContainerRuntimeBase> as unknown as IContainerRuntimeBase,
} satisfies Partial<IFluidParentContext> as unknown as IFluidParentContext;

const context = new testContext(
{
createSummarizerNodeFn,
id: "parent",
parentContext,
scope: {},
storage,
},
false,
false,
throwNYI,
);
context.registry = registry;
return context;
};

const createFactory = (
createDataStore?: IFluidDataStoreFactory["createDataStore"],
): IFluidDataStoreFactory => ({
type: "ChildDataStore",
get IFluidDataStoreFactory() {
return this;
},
instantiateDataStore: throwNYI,
createDataStore,
});

it("Child factory does not support synchronous creation", async () => {
const factory = createFactory();
const context = createContext([[factory.type, factory]]);
try {
context.createChildDataStoreSync(factory);
assert.fail("should fail");
} catch (e) {
assert(isFluidError(e));
assert(e.errorType === FluidErrorTypes.usageError);
assert(e.getTelemetryProperties().noCreateDataStore === true);
}
});

it("Child factory not registered", async () => {
const factory = createFactory();
const context = createContext();
try {
context.createChildDataStoreSync(factory);
assert.fail("should fail");
} catch (e) {
assert(isFluidError(e));
assert(e.errorType === FluidErrorTypes.usageError);
assert(e.getTelemetryProperties().isUndefined === true);
}
});

it("Child factory is a promise", async () => {
const factory = createFactory();
const context = createContext([[factory.type, Promise.resolve(factory)]]);

try {
context.createChildDataStoreSync(factory);
assert.fail("should fail");
} catch (e) {
assert(isFluidError(e));
assert(e.errorType === FluidErrorTypes.usageError);
assert(e.getTelemetryProperties().isPromise === true);
}
});

it("Child factory is a different instance", async () => {
const factory = createFactory();
const context = createContext([[factory.type, createFactory()]]);

try {
context.createChildDataStoreSync(factory);
assert.fail("should fail");
} catch (e) {
assert(isFluidError(e));
assert(e.errorType === FluidErrorTypes.usageError);
assert(e.getTelemetryProperties().diffInstance === true);
}
});

it("createChildDataStoreSync", async () => {
const factory = createFactory(() => ({ runtime: new MockFluidDataStoreRuntime() }));
const context = createContext([[factory.type, factory]]);
context.createChildDataStoreSync(factory);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export interface IFluidDataStoreChannel extends IDisposable {
export interface IFluidDataStoreContext extends IFluidParentContext {
// (undocumented)
readonly baseSnapshot: ISnapshotTree | undefined;
createChildDataStoreSync?<T extends IFluidDataStoreFactory>(childFactory: T): ReturnType<Exclude<T["createDataStore"], undefined>>;
// @deprecated (undocumented)
readonly createProps?: any;
// @deprecated (undocumented)
Expand All @@ -177,6 +178,9 @@ export const IFluidDataStoreFactory: keyof IProvideFluidDataStoreFactory;

// @alpha
export interface IFluidDataStoreFactory extends IProvideFluidDataStoreFactory {
createDataStore?(context: IFluidDataStoreContext): {
readonly runtime: IFluidDataStoreChannel;
};
instantiateDataStore(context: IFluidDataStoreContext, existing: boolean): Promise<IFluidDataStoreChannel>;
type: string;
}
Expand All @@ -186,8 +190,7 @@ export const IFluidDataStoreRegistry: keyof IProvideFluidDataStoreRegistry;

// @alpha
export interface IFluidDataStoreRegistry extends IProvideFluidDataStoreRegistry {
// (undocumented)
get(name: string): Promise<FluidDataStoreRegistryEntry | undefined>;
get(name: string): Promise<FluidDataStoreRegistryEntry | undefined> | FluidDataStoreRegistryEntry | undefined;
}

// @alpha
Expand Down Expand Up @@ -384,11 +387,17 @@ export interface LocalAttributionKey {
}

// @alpha
export type NamedFluidDataStoreRegistryEntries = Iterable<NamedFluidDataStoreRegistryEntry>;
export type NamedFluidDataStoreRegistryEntries = Iterable<NamedFluidDataStoreRegistryEntry2>;

// @alpha
export type NamedFluidDataStoreRegistryEntry = [string, Promise<FluidDataStoreRegistryEntry>];

// @alpha
export type NamedFluidDataStoreRegistryEntry2 = [
string,
Promise<FluidDataStoreRegistryEntry> | FluidDataStoreRegistryEntry
];

// @alpha
export interface OpAttributionKey {
seq: number;
Expand Down
27 changes: 26 additions & 1 deletion packages/runtime/runtime-definitions/src/dataStoreContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ import type {
} from "@fluidframework/driver-definitions/internal";
import type { IIdCompressor } from "@fluidframework/id-compressor";

import type { IProvideFluidDataStoreFactory } from "./dataStoreFactory.js";
import type {
IFluidDataStoreFactory,
IProvideFluidDataStoreFactory,
} from "./dataStoreFactory.js";
import type { IProvideFluidDataStoreRegistry } from "./dataStoreRegistry.js";
import type {
IGarbageCollectionData,
Expand Down Expand Up @@ -610,6 +613,28 @@ export interface IFluidDataStoreContext extends IFluidParentContext {
* and its children with the GC details from the previous summary.
*/
getBaseGCDetails(): Promise<IGarbageCollectionDetailsBase>;

/**
* This function creates a detached child data store synchronously.
*
* The `createChildDataStoreSync` method allows for the synchronous creation of a child data store. This is particularly
* useful in scenarios where immediate availability of the child data store is required, such as during the initialization
* of a parent data store, or when creation is in response to synchronous user input.
*
* In order for this function to succeed:
* 1. The parent data store's factory must also be an `IFluidDataStoreRegistry`.
* 2. The parent data store's registry must include the same instance as the provided child factory.
* 3. The parent data store's registry must synchronously provide the child factory.
* 4. The child factory must implement the `createDataStore` method.
*
* These invariants ensure that the child data store can also be created by a remote client running the same code as this client.
*
* @param childFactory - The factory of the data store to be created.
* @returns The created data store channel.
*/
createChildDataStoreSync?<T extends IFluidDataStoreFactory>(
Copy link
Contributor

@ChumpChief ChumpChief Nov 1, 2024

Choose a reason for hiding this comment

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

EDIT: I meant to put this comment in dataStoreFactory.ts, oops :)

I don't think I understand what the motivating scenario is for this PR, so I'd love to learn more about that. This comment is purely taking this PR at face value, so if I'm missing some special context let me know.

At a very high level, I'd question whether a sync factory pattern is moving in a good direction for our overall instantiation flows.

One pain point of Aqueduct (and therefore almost all of our current data store examples) is that the async startup flows are deferred to the data store instance (i.e. async initializingFirstTime, async hasInitialized). This leaves the instance in a prolonged "half-initialized" state which is prone to errors that don't account for certain invariants being violated.

Fluid basically mandates async somewhere in the construction flow though, due to retrieving handles, getChannel, etc.), so we can't go fully sync all the way to full initialization for most scenarios.

An alternative approach (ditching Aqueduct) would be to embrace an async factory, and move all async initialization up to the factory such that by the time we call the instance constructor, we are ready to be fully sync (and the instance is fully initialized in the constructor).

I've explored this pattern in https://github.com/microsoft/FluidFramework/blob/main/examples/utils/migration-tools/src/migrationTool.ts if you'd like an example. Fully admit there's some personal preference here, but I think this aligns better with DI principles, simplifies the instance class, and strengthens the instance class's invariants.

Copy link
Contributor Author

@anthony-murphy anthony-murphy Nov 1, 2024

Choose a reason for hiding this comment

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

scriptor has a case where they want to create a child datastore in response to user input, so there is no opportunity to do async actions.

i'd not confuse this with all instantiation, which include load, load must always be async. Create on the other hand can be synchronous, and that is what is enabled here. This matches how dds work.

We can't split the sync part and the async part here. the whole things need to be sync, as we need a net new datastore synchronously that ready to be used, and have its handle stores in the same js turn, to not break on user input.

Copy link
Contributor

Choose a reason for hiding this comment

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

I see, thank you - that makes more sense.

childFactory: T,
): ReturnType<Exclude<T["createDataStore"], undefined>>;
}

/**
Expand Down
Loading
Loading