diff --git a/packages/framework/aqueduct/package.json b/packages/framework/aqueduct/package.json index 4110b0784c7a..6ff79e74bd90 100644 --- a/packages/framework/aqueduct/package.json +++ b/packages/framework/aqueduct/package.json @@ -154,7 +154,11 @@ "typescript": "~5.4.5" }, "typeValidation": { - "broken": {}, + "broken": { + "Interface_IDataObjectProps": { + "forwardCompat": false + } + }, "entrypoint": "legacy" } } diff --git a/packages/framework/aqueduct/src/test/types/validateAqueductPrevious.generated.ts b/packages/framework/aqueduct/src/test/types/validateAqueductPrevious.generated.ts index b7fd21423392..7491dd0f6fd5 100644 --- a/packages/framework/aqueduct/src/test/types/validateAqueductPrevious.generated.ts +++ b/packages/framework/aqueduct/src/test/types/validateAqueductPrevious.generated.ts @@ -238,6 +238,7 @@ declare type current_as_old_for_Interface_DataObjectTypes = requireAssignableTo< * typeValidation.broken: * "Interface_IDataObjectProps": {"forwardCompat": false} */ +// @ts-expect-error compatibility expected to be broken declare type old_as_current_for_Interface_IDataObjectProps = requireAssignableTo, TypeOnly> /* diff --git a/packages/runtime/container-runtime/src/channelCollection.ts b/packages/runtime/container-runtime/src/channelCollection.ts index 01255683c30c..e52ebd6a0466 100644 --- a/packages/runtime/container-runtime/src/channelCollection.ts +++ b/packages/runtime/container-runtime/src/channelCollection.ts @@ -124,6 +124,7 @@ interface FluidDataStoreMessage { * Creates a shallow wrapper of {@link IFluidParentContext}. The wrapper can then have its methods overwritten as needed */ export function wrapContext(context: IFluidParentContext): IFluidParentContext { + const isReadOnly = context.isReadOnly; return { get IFluidDataStoreRegistry() { return context.IFluidDataStoreRegistry; @@ -149,6 +150,7 @@ export function wrapContext(context: IFluidParentContext): IFluidParentContext { get attachState() { return context.attachState; }, + isReadOnly: isReadOnly === undefined ? undefined : () => isReadOnly(), containerRuntime: context.containerRuntime, scope: context.scope, gcThrowOnTombstoneUsage: context.gcThrowOnTombstoneUsage, @@ -1116,6 +1118,31 @@ export class ChannelCollection implements IFluidDataStoreChannel, IDisposable { } } + /** + * Enumerates the contexts and calls notifyReadOnlyState on them. + */ + public notifyReadOnlyState(readonly: boolean): void { + for (const [fluidDataStoreId, context] of this.contexts) { + try { + context.notifyReadOnlyState(readonly); + } catch (error) { + this.mc.logger.sendErrorEvent( + { + eventName: "SetReadOnlyStateError", + ...tagCodeArtifacts({ + fluidDataStoreId, + }), + details: { + runtimeReadonly: this.parentContext.isReadOnly?.(), + readonly, + }, + }, + error, + ); + } + } + } + public setAttachState(attachState: AttachState.Attaching | AttachState.Attached): void { for (const [, context] of this.contexts) { // Fire only for bounded stores. diff --git a/packages/runtime/container-runtime/src/containerRuntime.ts b/packages/runtime/container-runtime/src/containerRuntime.ts index a8406effcecf..dda8c0b06c64 100644 --- a/packages/runtime/container-runtime/src/containerRuntime.ts +++ b/packages/runtime/container-runtime/src/containerRuntime.ts @@ -165,6 +165,7 @@ import { ContainerFluidHandleContext } from "./containerHandleContext.js"; import { channelToDataStore } from "./dataStore.js"; import { FluidDataStoreRegistry } from "./dataStoreRegistry.js"; import { + BaseDeltaManagerProxy, DeltaManagerPendingOpsProxy, DeltaManagerSummarizerProxy, } from "./deltaManagerProxies.js"; @@ -256,7 +257,6 @@ import { idCompressorBlobName, metadataBlobName, rootHasIsolatedChannels, - summarizerClientType, wrapSummaryInChannelsTree, formCreateSummarizerFn, summarizerRequestUrl, @@ -268,6 +268,7 @@ import { ISummaryConfiguration, DefaultSummaryConfiguration, isSummariesDisabled, + summarizerClientType, } from "./summary/index.js"; import { Throttler, formExponentialFn } from "./throttler.js"; @@ -1105,6 +1106,8 @@ export class ContainerRuntime return this._getAttachState(); } + public readonly isReadOnly = (): boolean => this.deltaManager.readOnlyInfo.readonly === true; + /** * Current session schema - defines what options are on & off. * It's overlap of document schema (controlled by summary & ops) and options controlling this session. @@ -1719,6 +1722,7 @@ export class ContainerRuntime new Map(dataStoreAliasMap), async (runtime: ChannelCollection) => provideEntryPoint, ); + this._deltaManager.on("readonly", this.notifyReadOnlyState); this.blobManager = new BlobManager({ routeContext: this.handleContext, @@ -2108,6 +2112,9 @@ export class ContainerRuntime this.pendingStateManager.dispose(); this.inboundBatchAggregator.dispose(); this.deltaScheduler.dispose(); + if (this._deltaManager instanceof BaseDeltaManagerProxy) { + this._deltaManager.dispose(); + } this.emit("dispose"); this.removeAllListeners(); } @@ -2560,6 +2567,9 @@ export class ContainerRuntime return this._loadIdCompressor; } + private readonly notifyReadOnlyState = (readonly: boolean): void => + this.channelCollection.notifyReadOnlyState(readonly); + public setConnectionState(connected: boolean, clientId?: string): void { // Validate we have consistent state const currentClientId = this._audience.getSelf()?.clientId; diff --git a/packages/runtime/container-runtime/src/dataStoreContext.ts b/packages/runtime/container-runtime/src/dataStoreContext.ts index 0d92b7a379ed..2a6d41a06b19 100644 --- a/packages/runtime/container-runtime/src/dataStoreContext.ts +++ b/packages/runtime/container-runtime/src/dataStoreContext.ts @@ -5,11 +5,17 @@ import { TypedEventEmitter, type ILayerCompatDetails } from "@fluid-internal/client-utils"; import { AttachState, IAudience } from "@fluidframework/container-definitions"; -import { IDeltaManager } from "@fluidframework/container-definitions/internal"; +import { + IDeltaManager, + isIDeltaManagerFull, + type IDeltaManagerFull, + type ReadOnlyInfo, +} from "@fluidframework/container-definitions/internal"; import { FluidObject, IDisposable, ITelemetryBaseProperties, + type IErrorBase, type IEvent, } from "@fluidframework/core-interfaces"; import { @@ -75,6 +81,7 @@ import { tagCodeArtifacts, } from "@fluidframework/telemetry-utils/internal"; +import { BaseDeltaManagerProxy } from "./deltaManagerProxies.js"; import { runtimeCompatDetailsForDataStore, validateDatastoreCompatibility, @@ -188,6 +195,58 @@ export interface IFluidDataStoreContextEvents extends IEvent { (event: "attaching" | "attached", listener: () => void); } +/** + * Eventually we should remove the delta manger from being exposed to Datastore runtimes via the context. However to remove that exposure we need to add new + * features, and those features themselves need forward and back compat. This proxy is here to enable that back compat. Each feature this proxy is used to + * support should be listed below, and as layer compat support goes away for those feature, we should also remove them from this proxy, with the eventual goal + * of completely removing this proxy. + * + * - Everything regarding readonly is to support older datastore runtimes which do not have the setReadonly function, so they must get their readonly state via the delta manager. + * + */ +class ContextDeltaManagerProxy extends BaseDeltaManagerProxy { + constructor( + base: IDeltaManagerFull, + private readonly isReadOnly: () => boolean, + ) { + super(base, { + onReadonly: (): void => { + /* readonly is controlled from the context which calls setReadonly */ + }, + }); + } + + public get readOnlyInfo(): ReadOnlyInfo { + const readonly = this.isReadOnly(); + if (readonly === this.deltaManager.readOnlyInfo.readonly) { + return this.deltaManager.readOnlyInfo; + } else { + return readonly === true + ? { + readonly, + forced: false, + permissions: undefined, + storageOnly: false, + } + : { readonly }; + } + } + + /** + * Called by the owning datastore context to configure the readonly + * state of the delta manger that is project down to the datastore + * runtime. This state may not align with that of the true delta + * manager if the context wishes to control the read only state + * differently than the delta manager itself. + */ + public setReadonly( + readonly: boolean, + readonlyConnectionReason?: { reason: string; error?: IErrorBase }, + ): void { + this.emit("readonly", readonly, readonlyConnectionReason); + } +} + /** * Represents the context for the store. This context is passed to the store runtime. * @internal @@ -217,10 +276,13 @@ export abstract class FluidDataStoreContext return this.parentContext.baseLogger; } + private readonly _contextDeltaManagerProxy: ContextDeltaManagerProxy; public get deltaManager(): IDeltaManager { - return this.parentContext.deltaManager; + return this._contextDeltaManagerProxy; } + public isReadOnly = (): boolean => this.parentContext.isReadOnly?.() ?? false; + public get connected(): boolean { return this.parentContext.connected; } @@ -420,6 +482,13 @@ export abstract class FluidDataStoreContext // By default, a data store can log maximum 10 local changes telemetry in summarizer. this.localChangesTelemetryCount = this.mc.config.getNumber("Fluid.Telemetry.LocalChangesTelemetryCount") ?? 10; + + assert(isIDeltaManagerFull(this.parentContext.deltaManager), "Invalid delta manager"); + + this._contextDeltaManagerProxy = new ContextDeltaManagerProxy( + this.parentContext.deltaManager, + () => this.isReadOnly(), + ); } public dispose(): void { @@ -437,6 +506,7 @@ export abstract class FluidDataStoreContext }) .catch((error) => {}); } + this._contextDeltaManagerProxy.dispose(); } /** @@ -615,6 +685,13 @@ export abstract class FluidDataStoreContext this.channel!.setConnectionState(connected, clientId); } + public notifyReadOnlyState(readonly: boolean): void { + this.verifyNotClosed("notifyReadOnlyState", false /* checkTombstone */); + + this.channel?.notifyReadOnlyState?.(readonly); + this._contextDeltaManagerProxy.setReadonly(readonly); + } + /** * Process messages for this data store. The messages here are contiguous messages for this data store in a batch. * @param messageCollection - The collection of messages to process. diff --git a/packages/runtime/container-runtime/src/deltaManagerProxies.ts b/packages/runtime/container-runtime/src/deltaManagerProxies.ts index ff8d83e1ba6a..f649cce76fcd 100644 --- a/packages/runtime/container-runtime/src/deltaManagerProxies.ts +++ b/packages/runtime/container-runtime/src/deltaManagerProxies.ts @@ -211,6 +211,7 @@ export abstract class BaseDeltaManagerProxy this.deltaManager.off("connect", this.eventHandlers.onConnect); this.deltaManager.off("disconnect", this.eventHandlers.onDisconnect); this.deltaManager.off("readonly", this.eventHandlers.onReadonly); + this.removeAllListeners(); } public submitSignal(content: string, targetClientId?: string): void { diff --git a/packages/runtime/container-runtime/src/runtimeLayerCompatState.ts b/packages/runtime/container-runtime/src/runtimeLayerCompatState.ts index cb307e45d97b..806e38791ea6 100644 --- a/packages/runtime/container-runtime/src/runtimeLayerCompatState.ts +++ b/packages/runtime/container-runtime/src/runtimeLayerCompatState.ts @@ -9,7 +9,10 @@ import { type ILayerCompatSupportRequirements, } from "@fluid-internal/client-utils"; import type { ICriticalContainerError } from "@fluidframework/container-definitions"; -import { encodeHandlesInContainerRuntime } from "@fluidframework/runtime-definitions/internal"; +import { + encodeHandlesInContainerRuntime, + notifiesReadOnlyState, +} from "@fluidframework/runtime-definitions/internal"; import { UsageError } from "@fluidframework/telemetry-utils/internal"; import { pkgVersion } from "./packageVersion.js"; @@ -66,7 +69,7 @@ export const runtimeCompatDetailsForDataStore: ILayerCompatDetails = { /** * The features supported by the Runtime layer across the Runtime / DataStore boundary. */ - supportedFeatures: new Set([encodeHandlesInContainerRuntime]), + supportedFeatures: new Set([encodeHandlesInContainerRuntime, notifiesReadOnlyState]), }; /** diff --git a/packages/runtime/container-runtime/src/test/createChildDataStoreSync.spec.ts b/packages/runtime/container-runtime/src/test/createChildDataStoreSync.spec.ts index af092942961e..c3658ece9745 100644 --- a/packages/runtime/container-runtime/src/test/createChildDataStoreSync.spec.ts +++ b/packages/runtime/container-runtime/src/test/createChildDataStoreSync.spec.ts @@ -18,7 +18,10 @@ import { type ISummarizerNodeWithGC, } from "@fluidframework/runtime-definitions/internal"; import { isFluidError } from "@fluidframework/telemetry-utils/internal"; -import { MockFluidDataStoreRuntime } from "@fluidframework/test-runtime-utils/internal"; +import { + MockDeltaManager, + MockFluidDataStoreRuntime, +} from "@fluidframework/test-runtime-utils/internal"; import { FluidDataStoreContext, @@ -89,6 +92,7 @@ describe("createChildDataStore", () => { }); }, } satisfies Partial as unknown as IContainerRuntimeBase, + deltaManager: new MockDeltaManager(), } satisfies Partial as unknown as IFluidParentContext; const context = new testContext( diff --git a/packages/runtime/container-runtime/src/test/dataStoreContext.spec.ts b/packages/runtime/container-runtime/src/test/dataStoreContext.spec.ts index 91e03300db7f..17b69b52f69e 100644 --- a/packages/runtime/container-runtime/src/test/dataStoreContext.spec.ts +++ b/packages/runtime/container-runtime/src/test/dataStoreContext.spec.ts @@ -47,6 +47,7 @@ import { isFluidError, } from "@fluidframework/telemetry-utils/internal"; import { + MockDeltaManager, MockFluidDataStoreRuntime, validateAssertionError, } from "@fluidframework/test-runtime-utils/internal"; @@ -245,6 +246,7 @@ describe("Data Store Context Tests", () => { parentContext = { IFluidDataStoreRegistry: registryWithSubRegistries, clientDetails: {} as unknown as IFluidParentContext["clientDetails"], + deltaManager: new MockDeltaManager(), } satisfies Partial as unknown as IFluidParentContext; localDataStoreContext = new LocalFluidDataStoreContext({ id: dataStoreId, @@ -541,6 +543,7 @@ describe("Data Store Context Tests", () => { IFluidDataStoreRegistry: registry, clientDetails: {} as unknown as IFluidParentContext["clientDetails"], containerRuntime: parentContext as unknown as IContainerRuntimeBase, + deltaManager: new MockDeltaManager(), } satisfies Partial as unknown as IFluidParentContext; }); @@ -1036,6 +1039,7 @@ describe("Data Store Context Tests", () => { IFluidDataStoreRegistry: registry, baseLogger: createChildLogger(), clientDetails: {} as unknown as IFluidParentContext["clientDetails"], + deltaManager: new MockDeltaManager(), } satisfies Partial as unknown as IFluidParentContext; }); diff --git a/packages/runtime/container-runtime/src/test/dataStoreCreation.spec.ts b/packages/runtime/container-runtime/src/test/dataStoreCreation.spec.ts index ab8d374b1871..f5e031f6d843 100644 --- a/packages/runtime/container-runtime/src/test/dataStoreCreation.spec.ts +++ b/packages/runtime/container-runtime/src/test/dataStoreCreation.spec.ts @@ -19,7 +19,10 @@ import { SummarizeInternalFn, } from "@fluidframework/runtime-definitions/internal"; import { createChildLogger } from "@fluidframework/telemetry-utils/internal"; -import { MockFluidDataStoreRuntime } from "@fluidframework/test-runtime-utils/internal"; +import { + MockDeltaManager, + MockFluidDataStoreRuntime, +} from "@fluidframework/test-runtime-utils/internal"; import { LocalFluidDataStoreContext } from "../dataStoreContext.js"; import { createRootSummarizerNodeWithGC } from "../summary/index.js"; @@ -111,6 +114,7 @@ describe("Data Store Creation Tests", () => { IFluidDataStoreRegistry: globalRegistry, baseLogger: createChildLogger(), clientDetails: {} as unknown as IFluidParentContext["clientDetails"], + deltaManager: new MockDeltaManager(), } satisfies Partial as unknown as IFluidParentContext; const summarizerNode = createRootSummarizerNodeWithGC( createChildLogger(), diff --git a/packages/runtime/container-runtime/src/test/dataStoreCreationHelper.ts b/packages/runtime/container-runtime/src/test/dataStoreCreationHelper.ts index 6d28b0ab8f42..bc793343a43b 100644 --- a/packages/runtime/container-runtime/src/test/dataStoreCreationHelper.ts +++ b/packages/runtime/container-runtime/src/test/dataStoreCreationHelper.ts @@ -18,7 +18,10 @@ import { type SummarizeInternalFn, } from "@fluidframework/runtime-definitions/internal"; import { createChildLogger } from "@fluidframework/telemetry-utils/internal"; -import { MockFluidDataStoreRuntime } from "@fluidframework/test-runtime-utils/internal"; +import { + MockDeltaManager, + MockFluidDataStoreRuntime, +} from "@fluidframework/test-runtime-utils/internal"; import { LocalFluidDataStoreContext, @@ -58,6 +61,7 @@ export function createParentContext( baseLogger: logger, clientDetails, submitMessage: () => {}, + deltaManager: new MockDeltaManager(), } satisfies Partial as unknown as IFluidParentContext; } diff --git a/packages/runtime/datastore-definitions/api-report/datastore-definitions.legacy.alpha.api.md b/packages/runtime/datastore-definitions/api-report/datastore-definitions.legacy.alpha.api.md index 248e1cf99128..956235c6c2c2 100644 --- a/packages/runtime/datastore-definitions/api-report/datastore-definitions.legacy.alpha.api.md +++ b/packages/runtime/datastore-definitions/api-report/datastore-definitions.legacy.alpha.api.md @@ -91,6 +91,7 @@ export interface IFluidDataStoreRuntime extends IEventProvider boolean; // (undocumented) readonly logger: ITelemetryBaseLogger; // (undocumented) @@ -120,6 +121,8 @@ export interface IFluidDataStoreRuntimeEvents extends IEvent { (event: "signal", listener: (message: IInboundSignalMessage, local: boolean) => void): any; // (undocumented) (event: "connected", listener: (clientId: string) => void): any; + // (undocumented) + (event: "readonly", listener: (isReadOnly: boolean) => void): any; } // @alpha (undocumented) diff --git a/packages/runtime/datastore-definitions/src/dataStoreRuntime.ts b/packages/runtime/datastore-definitions/src/dataStoreRuntime.ts index 2df27c7e30b7..5c5517d85d02 100644 --- a/packages/runtime/datastore-definitions/src/dataStoreRuntime.ts +++ b/packages/runtime/datastore-definitions/src/dataStoreRuntime.ts @@ -34,6 +34,11 @@ export interface IFluidDataStoreRuntimeEvents extends IEvent { (event: "op", listener: (message: ISequencedDocumentMessage) => void); (event: "signal", listener: (message: IInboundSignalMessage, local: boolean) => void); (event: "connected", listener: (clientId: string) => void); + /* + * The readonly event is fired when the readonly state of the datastore runtime changes. + * The isReadOnly param will express the new readonly state. + */ + (event: "readonly", listener: (isReadOnly: boolean) => void); } /** @@ -69,6 +74,12 @@ export interface IFluidDataStoreRuntime readonly connected: boolean; + /** + * Get the current readonly state. + * @returns true if read-only, otherwise false + */ + readonly isReadOnly: () => boolean; + readonly logger: ITelemetryBaseLogger; /** diff --git a/packages/runtime/datastore/api-report/datastore.legacy.alpha.api.md b/packages/runtime/datastore/api-report/datastore.legacy.alpha.api.md index 0ccee30275df..fa42d27c836b 100644 --- a/packages/runtime/datastore/api-report/datastore.legacy.alpha.api.md +++ b/packages/runtime/datastore/api-report/datastore.legacy.alpha.api.md @@ -65,8 +65,11 @@ export class FluidDataStoreRuntime extends TypedEventEmitter boolean; + // (undocumented) get logger(): ITelemetryLoggerExt; makeVisibleAndAttachGraph(): void; + notifyReadOnlyState(readonly: boolean): void; // (undocumented) get objectsRoutingContext(): this; // (undocumented) diff --git a/packages/runtime/datastore/package.json b/packages/runtime/datastore/package.json index 94fc59092489..487d78fd629c 100644 --- a/packages/runtime/datastore/package.json +++ b/packages/runtime/datastore/package.json @@ -158,7 +158,11 @@ "typescript": "~5.4.5" }, "typeValidation": { - "broken": {}, + "broken": { + "Class_FluidDataStoreRuntime": { + "forwardCompat": false + } + }, "entrypoint": "legacy" } } diff --git a/packages/runtime/datastore/src/dataStoreRuntime.ts b/packages/runtime/datastore/src/dataStoreRuntime.ts index ea1bc3e40e3d..84ac912f6188 100644 --- a/packages/runtime/datastore/src/dataStoreRuntime.ts +++ b/packages/runtime/datastore/src/dataStoreRuntime.ts @@ -57,6 +57,7 @@ import { IInboundSignalMessage, type IRuntimeMessageCollection, type IRuntimeMessagesContent, + notifiesReadOnlyState, encodeHandlesInContainerRuntime, } from "@fluidframework/runtime-definitions/internal"; import { @@ -104,8 +105,12 @@ import { import { pkgVersion } from "./packageVersion.js"; import { RemoteChannelContext } from "./remoteChannelContext.js"; +type PickRequired, K extends keyof T> = Omit & + Required>; + interface IFluidDataStoreContextFeaturesToTypes { [encodeHandlesInContainerRuntime]: IFluidDataStoreContext; // No difference in typing with this feature + [notifiesReadOnlyState]: PickRequired; } function contextSupportsFeature( @@ -154,6 +159,11 @@ export class FluidDataStoreRuntime return this.dataStoreContext.connected; } + /** + * {@inheritDoc @fluidframework/datastore-definitions#IFluidDataStoreRuntime.isReadOnly} + */ + public readonly isReadOnly = (): boolean => this._readonly; + public get clientId(): string | undefined { return this.dataStoreContext.clientId; } @@ -281,6 +291,15 @@ export class FluidDataStoreRuntime dataStoreContext as FluidObject; validateRuntimeCompatibility(runtimeCompatDetails, this.dispose.bind(this)); + if (contextSupportsFeature(dataStoreContext, notifiesReadOnlyState)) { + this._readonly = dataStoreContext.isReadOnly(); + } else { + this._readonly = this.dataStoreContext.deltaManager.readOnlyInfo.readonly === true; + this.dataStoreContext.deltaManager.on("readonly", (readonly) => + this.notifyReadOnlyState(readonly), + ); + } + this.submitMessagesWithoutEncodingHandles = contextSupportsFeature( dataStoreContext, encodeHandlesInContainerRuntime, @@ -668,6 +687,20 @@ export class FluidDataStoreRuntime raiseConnectedEvent(this.logger, this, connected, clientId); } + private _readonly: boolean; + /** + * This function is used by the datastore context to configure the + * readonly state of this object. It should not be invoked by + * any other callers. + */ + public notifyReadOnlyState(readonly: boolean): void { + this.verifyNotClosed(); + if (readonly !== this._readonly) { + this._readonly = readonly; + this.emit("readonly", readonly); + } + } + public getQuorum(): IQuorumClients { return this.quorum; } diff --git a/packages/runtime/datastore/src/test/types/validateDatastorePrevious.generated.ts b/packages/runtime/datastore/src/test/types/validateDatastorePrevious.generated.ts index bab8d7182452..5fe3440cb5cc 100644 --- a/packages/runtime/datastore/src/test/types/validateDatastorePrevious.generated.ts +++ b/packages/runtime/datastore/src/test/types/validateDatastorePrevious.generated.ts @@ -22,6 +22,7 @@ declare type MakeUnusedImportErrorsGoAway = TypeOnly | MinimalType | Fu * typeValidation.broken: * "Class_FluidDataStoreRuntime": {"forwardCompat": false} */ +// @ts-expect-error compatibility expected to be broken declare type old_as_current_for_Class_FluidDataStoreRuntime = requireAssignableTo, TypeOnly> /* diff --git a/packages/runtime/runtime-definitions/api-report/runtime-definitions.legacy.alpha.api.md b/packages/runtime/runtime-definitions/api-report/runtime-definitions.legacy.alpha.api.md index d497b1a40dff..95cd8e251a8e 100644 --- a/packages/runtime/runtime-definitions/api-report/runtime-definitions.legacy.alpha.api.md +++ b/packages/runtime/runtime-definitions/api-report/runtime-definitions.legacy.alpha.api.md @@ -129,6 +129,7 @@ export interface IFluidDataStoreChannel extends IDisposable { getAttachSummary(telemetryContext?: ITelemetryContext): ISummaryTreeWithStats; getGCData(fullGC?: boolean): Promise; makeVisibleAndAttachGraph(): void; + notifyReadOnlyState?(readonly: boolean): void; processMessages(messageCollection: IRuntimeMessageCollection): void; processSignal(message: IInboundSignalMessage, local: boolean): void; // (undocumented) @@ -214,6 +215,7 @@ export interface IFluidParentContext extends IProvideFluidHandleContext, Partial getQuorum(): IQuorumClients; // (undocumented) readonly idCompressor?: IIdCompressor; + readonly isReadOnly?: () => boolean; readonly loadingGroupId?: string; makeLocallyVisible(): void; // (undocumented) diff --git a/packages/runtime/runtime-definitions/src/dataStoreContext.ts b/packages/runtime/runtime-definitions/src/dataStoreContext.ts index 5530e9d02f94..99bc07713aa0 100644 --- a/packages/runtime/runtime-definitions/src/dataStoreContext.ts +++ b/packages/runtime/runtime-definitions/src/dataStoreContext.ts @@ -370,6 +370,11 @@ export interface IFluidDataStoreChannel extends IDisposable { */ setConnectionState(connected: boolean, clientId?: string); + /** + * Notifies this object about changes in the readonly state + */ + notifyReadOnlyState?(readonly: boolean): void; + /** * Ask the DDS to resubmit a message. This could be because we reconnected and this message was not acked. * @param type - The type of the original message. @@ -440,6 +445,11 @@ export interface IFluidParentContext readonly options: Record; readonly clientId: string | undefined; readonly connected: boolean; + /** + * Indicates if the parent context is readonly. If isReadOnly is true, the consumer of + * the context should also consider themselves readonly. + */ + readonly isReadOnly?: () => boolean; readonly deltaManager: IDeltaManager; readonly storage: IDocumentStorageService; readonly baseLogger: ITelemetryBaseLogger; diff --git a/packages/runtime/runtime-definitions/src/index.ts b/packages/runtime/runtime-definitions/src/index.ts index 14178798eaa6..e4f17c9846e9 100644 --- a/packages/runtime/runtime-definitions/src/index.ts +++ b/packages/runtime/runtime-definitions/src/index.ts @@ -53,7 +53,10 @@ export type { IRuntimeMessagesContent, ISequencedMessageEnvelope, } from "./protocol.js"; -export { encodeHandlesInContainerRuntime } from "./runtimeLayerCompatFeatureNames.js"; +export { + encodeHandlesInContainerRuntime, + notifiesReadOnlyState, +} from "./runtimeLayerCompatFeatureNames.js"; export type { CreateChildSummarizerNodeParam, IExperimentalIncrementalSummaryContext, diff --git a/packages/runtime/runtime-definitions/src/runtimeLayerCompatFeatureNames.ts b/packages/runtime/runtime-definitions/src/runtimeLayerCompatFeatureNames.ts index c95551b20411..03c7a226bb01 100644 --- a/packages/runtime/runtime-definitions/src/runtimeLayerCompatFeatureNames.ts +++ b/packages/runtime/runtime-definitions/src/runtimeLayerCompatFeatureNames.ts @@ -10,3 +10,10 @@ * @internal */ export const encodeHandlesInContainerRuntime = "encodeHandlesInContainerRuntime" as const; + +/** + * This feature indicates that the datastore context will call notifyReadOnlyState on the + * datastore runtime. + * @internal + */ +export const notifiesReadOnlyState = "notifiesReadOnlyState" as const; diff --git a/packages/runtime/test-runtime-utils/api-report/test-runtime-utils.legacy.alpha.api.md b/packages/runtime/test-runtime-utils/api-report/test-runtime-utils.legacy.alpha.api.md index 5857ed971dbf..08ce7f7ecb0c 100644 --- a/packages/runtime/test-runtime-utils/api-report/test-runtime-utils.legacy.alpha.api.md +++ b/packages/runtime/test-runtime-utils/api-report/test-runtime-utils.legacy.alpha.api.md @@ -196,7 +196,7 @@ export class MockDeltaConnection implements IDeltaConnection { // @alpha export class MockDeltaManager extends TypedEventEmitter implements IDeltaManager { - constructor(getClientId?: (() => string) | undefined); + constructor(getClientId?: (() => string | undefined) | undefined); // (undocumented) active: boolean; // (undocumented) @@ -354,6 +354,8 @@ export class MockFluidDataStoreContext implements IFluidDataStoreContext { // (undocumented) packagePath: readonly string[]; // (undocumented) + readonly: boolean; + // (undocumented) scope: FluidObject; // (undocumented) setChannelDirty(address: string): void; @@ -447,6 +449,8 @@ export class MockFluidDataStoreRuntime extends EventEmitter implements IFluidDat // (undocumented) get isAttached(): boolean; // (undocumented) + readonly isReadOnly: () => boolean; + // (undocumented) readonly loader: ILoader; // @deprecated (undocumented) get local(): boolean; @@ -456,6 +460,8 @@ export class MockFluidDataStoreRuntime extends EventEmitter implements IFluidDat // (undocumented) makeVisibleAndAttachGraph(): void; // (undocumented) + notifyReadOnlyState(readonly: boolean): void; + // (undocumented) get objectsRoutingContext(): IFluidHandleContext; // (undocumented) options: Record; diff --git a/packages/runtime/test-runtime-utils/package.json b/packages/runtime/test-runtime-utils/package.json index 7818e18b458e..acc81b0fe654 100644 --- a/packages/runtime/test-runtime-utils/package.json +++ b/packages/runtime/test-runtime-utils/package.json @@ -153,7 +153,14 @@ "typescript": "~5.4.5" }, "typeValidation": { - "broken": {}, + "broken": { + "Class_MockFluidDataStoreContext": { + "forwardCompat": false + }, + "Class_MockFluidDataStoreRuntime": { + "forwardCompat": false + } + }, "entrypoint": "legacy" } } diff --git a/packages/runtime/test-runtime-utils/src/mockDeltas.ts b/packages/runtime/test-runtime-utils/src/mockDeltas.ts index bef0e266e26c..60082b51afbd 100644 --- a/packages/runtime/test-runtime-utils/src/mockDeltas.ts +++ b/packages/runtime/test-runtime-utils/src/mockDeltas.ts @@ -186,7 +186,7 @@ export class MockDeltaManager this.emit("op", message); } - constructor(private readonly getClientId?: () => string) { + constructor(private readonly getClientId?: () => string | undefined) { super(); this._inbound = new MockDeltaQueue(); diff --git a/packages/runtime/test-runtime-utils/src/mocks.ts b/packages/runtime/test-runtime-utils/src/mocks.ts index 15636ce00a6a..3db4d1daa3a1 100644 --- a/packages/runtime/test-runtime-utils/src/mocks.ts +++ b/packages/runtime/test-runtime-utils/src/mocks.ts @@ -841,6 +841,8 @@ export class MockFluidDataStoreRuntime this.registry = new Map(registry.map((factory) => [factory.type, factory])); } } + private readonly: boolean = false; + public readonly isReadOnly = () => this.readonly; public readonly entryPoint: IFluidHandleInternal; @@ -1039,6 +1041,10 @@ export class MockFluidDataStoreRuntime return; } + public notifyReadOnlyState(readonly: boolean): void { + this.readonly = readonly; + } + public async resolveHandle(request: IRequest): Promise { if (request.url !== undefined) { return { diff --git a/packages/runtime/test-runtime-utils/src/mocksDataStoreContext.ts b/packages/runtime/test-runtime-utils/src/mocksDataStoreContext.ts index 33126e144086..c05e4996a68e 100644 --- a/packages/runtime/test-runtime-utils/src/mocksDataStoreContext.ts +++ b/packages/runtime/test-runtime-utils/src/mocksDataStoreContext.ts @@ -33,6 +33,8 @@ import { } from "@fluidframework/telemetry-utils/internal"; import { v4 as uuid } from "uuid"; +import { MockDeltaManager } from "./mockDeltas.js"; + /** * @legacy * @alpha @@ -45,9 +47,10 @@ export class MockFluidDataStoreContext implements IFluidDataStoreContext { public clientId: string | undefined = uuid(); public clientDetails: IClientDetails; public connected: boolean = true; + public readonly: boolean = false; public baseSnapshot: ISnapshotTree | undefined; public deltaManager: IDeltaManager = - undefined as any; + new MockDeltaManager(() => this.clientId); public containerRuntime: IContainerRuntimeBase = undefined as any; public storage: IDocumentStorageService = undefined as any; public IFluidDataStoreRegistry: IFluidDataStoreRegistry = undefined as any; diff --git a/packages/runtime/test-runtime-utils/src/test/types/validateTestRuntimeUtilsPrevious.generated.ts b/packages/runtime/test-runtime-utils/src/test/types/validateTestRuntimeUtilsPrevious.generated.ts index 60347db4f053..687153b795f6 100644 --- a/packages/runtime/test-runtime-utils/src/test/types/validateTestRuntimeUtilsPrevious.generated.ts +++ b/packages/runtime/test-runtime-utils/src/test/types/validateTestRuntimeUtilsPrevious.generated.ts @@ -166,6 +166,7 @@ declare type current_as_old_for_Class_MockDeltaQueue = requireAssignableTo, TypeOnly> /* @@ -184,6 +185,7 @@ declare type current_as_old_for_Class_MockFluidDataStoreContext = requireAssigna * typeValidation.broken: * "Class_MockFluidDataStoreRuntime": {"forwardCompat": false} */ +// @ts-expect-error compatibility expected to be broken declare type old_as_current_for_Class_MockFluidDataStoreRuntime = requireAssignableTo, TypeOnly> /* diff --git a/packages/test/local-server-tests/src/test/readonly.spec.ts b/packages/test/local-server-tests/src/test/readonly.spec.ts new file mode 100644 index 000000000000..2dd499337f72 --- /dev/null +++ b/packages/test/local-server-tests/src/test/readonly.spec.ts @@ -0,0 +1,229 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { type IRuntimeFactory } from "@fluidframework/container-definitions/internal"; +import { + createDetachedContainer, + loadExistingContainer, + type ILoadExistingContainerProps, +} from "@fluidframework/container-loader/internal"; +import { loadContainerRuntime } from "@fluidframework/container-runtime/internal"; +import { type FluidObject } from "@fluidframework/core-interfaces/internal"; +import { assert } from "@fluidframework/core-utils/internal"; +import { FluidDataStoreRuntime } from "@fluidframework/datastore/internal"; +import type { + IChannelFactory, + IFluidDataStoreRuntime, +} from "@fluidframework/datastore-definitions/internal"; +import { SharedMap, ISharedMap } from "@fluidframework/map/internal"; +import type { IFluidDataStoreFactory } from "@fluidframework/runtime-definitions/internal"; +import { LocalDeltaConnectionServer } from "@fluidframework/server-local-server"; + +import { createLoader } from "../utils.js"; + +const mapFactory = SharedMap.getFactory(); +const sharedObjectRegistry = new Map([[mapFactory.type, mapFactory]]); + +class DefaultDataStore { + public static create(runtime: IFluidDataStoreRuntime) { + const root = SharedMap.create(runtime, "root"); + root.bindToContext(); + return new DefaultDataStore(runtime, root); + } + + public static async load(runtime: IFluidDataStoreRuntime) { + const root = (await runtime.getChannel("root")) as unknown as ISharedMap; + return new DefaultDataStore(runtime, root); + } + + public readonlyEventCount = 0; + + private constructor( + private readonly runtime: IFluidDataStoreRuntime, + sharedMap: SharedMap, + ) { + this.runtime.on("readonly", () => this.readonlyEventCount++); + } + + get DefaultDataStore() { + return this; + } + isReadOnly() { + return this.runtime.isReadOnly(); + } + + get handle() { + return this.runtime.entryPoint; + } +} + +class DefaultDataStoreFactory implements IFluidDataStoreFactory { + static readonly instance = new DefaultDataStoreFactory(); + private constructor() {} + + get IFluidDataStoreFactory() { + return this; + } + + public readonly type = "DefaultDataStore"; + + async instantiateDataStore(context, existing) { + const runtime: FluidDataStoreRuntime = new FluidDataStoreRuntime( + context, + sharedObjectRegistry, + existing, + async () => dataStore, + ); + const dataStore = existing + ? DefaultDataStore.load(runtime) + : DefaultDataStore.create(runtime); + + return runtime; + } +} +// a simple container runtime factory with a single datastore aliased as default. +// the default datastore is also returned as the entrypoint +const runtimeFactory: IRuntimeFactory = { + get IRuntimeFactory() { + return this; + }, + instantiateRuntime: async (context, existing) => { + return loadContainerRuntime({ + context, + existing, + registryEntries: [ + [ + DefaultDataStoreFactory.instance.type, + // the parent is still async in the container registry + // this allows things like code splitting for dynamic loading + Promise.resolve(DefaultDataStoreFactory.instance), + ], + ], + provideEntryPoint: async (rt) => { + const maybeRoot = await rt.getAliasedDataStoreEntryPoint("default"); + if (maybeRoot === undefined) { + const ds = await rt.createDataStore(DefaultDataStoreFactory.instance.type); + await ds.trySetAlias("default"); + } + const root = await rt.getAliasedDataStoreEntryPoint("default"); + assert(root !== undefined, "default must exist"); + return root.get(); + }, + }); + }, +}; + +async function createContainerAndGetLoadProps(): Promise { + const deltaConnectionServer = LocalDeltaConnectionServer.create(); + + const { loaderProps, codeDetails, urlResolver } = createLoader({ + deltaConnectionServer, + runtimeFactory, + }); + + const container = await createDetachedContainer({ ...loaderProps, codeDetails }); + await container.getEntryPoint(); + + await container.attach(urlResolver.createCreateNewRequest("test")); + const url = await container.getAbsoluteUrl(""); + assert(url !== undefined, "container must have url"); + container.dispose(); + return { ...loaderProps, request: { url } }; +} + +describe("readonly", () => { + it("Readonly is correct across container create", async () => { + const deltaConnectionServer = LocalDeltaConnectionServer.create(); + + const { loaderProps, codeDetails, urlResolver } = createLoader({ + deltaConnectionServer, + runtimeFactory, + }); + + const container = await createDetachedContainer({ ...loaderProps, codeDetails }); + + const entrypoint: FluidObject = await container.getEntryPoint(); + + assert( + entrypoint.DefaultDataStore !== undefined, + "container entrypoint must be DefaultDataStore", + ); + + assert(entrypoint.DefaultDataStore.isReadOnly() === false, "shouldn't be readonly"); + assert( + entrypoint.DefaultDataStore.readonlyEventCount === 0, + "shouldn't be any readonly events", + ); + + await container.attach(urlResolver.createCreateNewRequest("test")); + + assert(entrypoint.DefaultDataStore.isReadOnly() === false, "shouldn't be readonly"); + assert( + entrypoint.DefaultDataStore.readonlyEventCount === 0, + "shouldn't be any readonly events", + ); + }); + + it("Readonly is correct after container load", async () => { + const loadedContainer = await loadExistingContainer( + await createContainerAndGetLoadProps(), + ); + + const entrypoint: FluidObject = await loadedContainer.getEntryPoint(); + + assert( + entrypoint.DefaultDataStore !== undefined, + "container entrypoint must be DefaultDataStore", + ); + + assert(entrypoint.DefaultDataStore.isReadOnly() === false, "shouldn't be readonly"); + assert( + entrypoint.DefaultDataStore.readonlyEventCount === 0, + "shouldn't be any readonly events", + ); + }); + + it("Readonly is correct after datastore load and forceReadonly", async () => { + const loadedContainer = await loadExistingContainer( + await createContainerAndGetLoadProps(), + ); + + const entrypoint: FluidObject = await loadedContainer.getEntryPoint(); + + assert( + entrypoint.DefaultDataStore !== undefined, + "container entrypoint must be DefaultDataStore", + ); + + loadedContainer.forceReadonly?.(true); + + assert(entrypoint.DefaultDataStore.isReadOnly() === true, "should be readonly"); + assert( + entrypoint.DefaultDataStore.readonlyEventCount === 1, + "should be any readonly events", + ); + }); + + it("Readonly is correct after forceReadonly before datastore load", async () => { + const loadedContainer = await loadExistingContainer( + await createContainerAndGetLoadProps(), + ); + + loadedContainer.forceReadonly?.(true); + + const entrypoint: FluidObject = await loadedContainer.getEntryPoint(); + + assert( + entrypoint.DefaultDataStore !== undefined, + "container entrypoint must be DefaultDataStore", + ); + + assert(entrypoint.DefaultDataStore.isReadOnly() === true, "should be readonly"); + assert( + entrypoint.DefaultDataStore.readonlyEventCount === 0, + "shouldn't be any readonly events", + ); + }); +}); diff --git a/packages/test/test-utils/package.json b/packages/test/test-utils/package.json index 34c3aef7be8f..8396d8b2d4fb 100644 --- a/packages/test/test-utils/package.json +++ b/packages/test/test-utils/package.json @@ -163,7 +163,14 @@ "typescript": "~5.4.5" }, "typeValidation": { - "broken": {}, + "broken": { + "Interface_IProvideTestFluidObject": { + "forwardCompat": false + }, + "Interface_ITestFluidObject": { + "forwardCompat": false + } + }, "entrypoint": "legacy" } } diff --git a/packages/test/test-utils/src/test/types/validateTestUtilsPrevious.generated.ts b/packages/test/test-utils/src/test/types/validateTestUtilsPrevious.generated.ts index 23bb8b2365d0..cf9cc8cc4a1e 100644 --- a/packages/test/test-utils/src/test/types/validateTestUtilsPrevious.generated.ts +++ b/packages/test/test-utils/src/test/types/validateTestUtilsPrevious.generated.ts @@ -76,6 +76,7 @@ declare type current_as_old_for_Interface_IOpProcessingController = requireAssig * typeValidation.broken: * "Interface_IProvideTestFluidObject": {"forwardCompat": false} */ +// @ts-expect-error compatibility expected to be broken declare type old_as_current_for_Interface_IProvideTestFluidObject = requireAssignableTo, TypeOnly> /* @@ -94,6 +95,7 @@ declare type current_as_old_for_Interface_IProvideTestFluidObject = requireAssig * typeValidation.broken: * "Interface_ITestFluidObject": {"forwardCompat": false} */ +// @ts-expect-error compatibility expected to be broken declare type old_as_current_for_Interface_ITestFluidObject = requireAssignableTo, TypeOnly> /*