From 3a9079c7ef676592608aa622b24300dd53ae4a40 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Wed, 29 Oct 2025 13:03:37 -0700 Subject: [PATCH 1/5] DataStoreRuntime: Expose activeLocalOperationActivity --- .../datastore-definitions.legacy.alpha.api.md | 1 + .../datastore-definitions.legacy.beta.api.md | 1 + .../src/dataStoreRuntime.ts | 15 +++ .../api-report/datastore.legacy.beta.api.md | 2 + packages/runtime/datastore/package.json | 3 +- .../runtime/datastore/src/dataStoreRuntime.ts | 117 ++++++++++-------- .../validateDatastorePrevious.generated.ts | 1 + 7 files changed, 90 insertions(+), 50 deletions(-) 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 d2cc0b17c8f8..0213e9851da7 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 @@ -70,6 +70,7 @@ export type IDeltaManagerErased = ErasedType<"@fluidframework/container-definiti // @beta @sealed @legacy export interface IFluidDataStoreRuntime extends IEventProvider, IDisposable { + readonly activeLocalOperationActivity?: "applyStashed" | "rollback" | undefined; addChannel(channel: IChannel): void; readonly attachState: AttachState; bindChannel(channel: IChannel): void; diff --git a/packages/runtime/datastore-definitions/api-report/datastore-definitions.legacy.beta.api.md b/packages/runtime/datastore-definitions/api-report/datastore-definitions.legacy.beta.api.md index 3001c80a504e..056c9f40f5f0 100644 --- a/packages/runtime/datastore-definitions/api-report/datastore-definitions.legacy.beta.api.md +++ b/packages/runtime/datastore-definitions/api-report/datastore-definitions.legacy.beta.api.md @@ -70,6 +70,7 @@ export type IDeltaManagerErased = ErasedType<"@fluidframework/container-definiti // @beta @sealed @legacy export interface IFluidDataStoreRuntime extends IEventProvider, IDisposable { + readonly activeLocalOperationActivity?: "applyStashed" | "rollback" | undefined; addChannel(channel: IChannel): void; readonly attachState: AttachState; bindChannel(channel: IChannel): void; diff --git a/packages/runtime/datastore-definitions/src/dataStoreRuntime.ts b/packages/runtime/datastore-definitions/src/dataStoreRuntime.ts index 46d87690b4eb..132dfe00ab4c 100644 --- a/packages/runtime/datastore-definitions/src/dataStoreRuntime.ts +++ b/packages/runtime/datastore-definitions/src/dataStoreRuntime.ts @@ -174,6 +174,21 @@ export interface IFluidDataStoreRuntime * with it. */ readonly entryPoint: IFluidHandle; + + /** + * Indicates the current local operation activity being performed by the data store runtime. + * + * This property allows consumers to know when the runtime itself is actively making changes to data store DDSes. + * When this property is not `undefined`, consumers should expect to see state modifications initiated by the runtime + * rather than by the consumer directly: + * - `"applyStashed"` - The runtime is applying previously stashed operations during reconnection or container load. + * Stashed operations are local changes that were submitted but not yet acknowledged when a container was closed, + * and are being reapplied to restore the expected local state. + * - `"rollback"` - The runtime is rolling back (reverting) local operations that the user has chosen not to submit. + * This occurs when operations are being discarded, such as when exiting staging mode without committing changes. + * - `undefined` - No local operation activity is currently in progress. + */ + readonly activeLocalOperationActivity?: "applyStashed" | "rollback" | undefined; } /** diff --git a/packages/runtime/datastore/api-report/datastore.legacy.beta.api.md b/packages/runtime/datastore/api-report/datastore.legacy.beta.api.md index 0b8e68c9608b..735d1cfe1173 100644 --- a/packages/runtime/datastore/api-report/datastore.legacy.beta.api.md +++ b/packages/runtime/datastore/api-report/datastore.legacy.beta.api.md @@ -17,6 +17,8 @@ export class FluidDataStoreRuntime extends TypedEventEmitter Promise, policies?: Partial); // (undocumented) get absolutePath(): string; + // (undocumented) + get activeLocalOperationActivity(): "applyStashed" | "rollback" | undefined; addChannel(channel: IChannel): void; // (undocumented) applyStashedOp(content: any): Promise; diff --git a/packages/runtime/datastore/package.json b/packages/runtime/datastore/package.json index 98c60f019e8d..2bb8142d6df9 100644 --- a/packages/runtime/datastore/package.json +++ b/packages/runtime/datastore/package.json @@ -159,7 +159,8 @@ "typeValidation": { "broken": { "Class_FluidDataStoreRuntime": { - "backCompat": false + "backCompat": false, + "forwardCompat": false }, "ClassStatics_FluidDataStoreRuntime": { "backCompat": false diff --git a/packages/runtime/datastore/src/dataStoreRuntime.ts b/packages/runtime/datastore/src/dataStoreRuntime.ts index d570ffc95b0d..28a840f3ce20 100644 --- a/packages/runtime/datastore/src/dataStoreRuntime.ts +++ b/packages/runtime/datastore/src/dataStoreRuntime.ts @@ -233,6 +233,11 @@ export class FluidDataStoreRuntime return this; } + private localOpActivity: "applyStashed" | "rollback" | undefined = undefined; + public get activeLocalOperationActivity(): "applyStashed" | "rollback" | undefined { + return this.localOpActivity; + } + private _disposed = false; public get disposed(): boolean { return this._disposed; @@ -1299,69 +1304,83 @@ export class FluidDataStoreRuntime localOpMetadata: unknown, ): void { this.verifyNotClosed(); + assert(!this.localOpActivity, "runtime active must be undefined when entering rollback"); + this.localOpActivity = "rollback"; + try { + // The op being rolled back was not/will not be submitted, so decrement the count. + --this.pendingOpCount.value; - // The op being rolled back was not/will not be submitted, so decrement the count. - --this.pendingOpCount.value; - - switch (type) { - case DataStoreMessageType.ChannelOp: { - // For Operations, find the right channel and trigger resubmission on it. - const envelope = content as IEnvelope; - const channelContext = this.contexts.get(envelope.address); - assert(!!channelContext, 0x2ed /* "There should be a channel context for the op" */); + switch (type) { + case DataStoreMessageType.ChannelOp: { + // For Operations, find the right channel and trigger resubmission on it. + const envelope = content as IEnvelope; + const channelContext = this.contexts.get(envelope.address); + assert(!!channelContext, 0x2ed /* "There should be a channel context for the op" */); - channelContext.rollback(envelope.contents, localOpMetadata); - break; - } - default: { - throw new LoggingError(`Can't rollback ${type} message`); + channelContext.rollback(envelope.contents, localOpMetadata); + break; + } + default: { + throw new LoggingError(`Can't rollback ${type} message`); + } } + } finally { + this.localOpActivity = undefined; } } // TODO: use something other than `any` here // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any public async applyStashedOp(content: any): Promise { - // The op being applied may have been submitted in a previous session, so we increment the count here. - // Either the ack will arrive and be processed, or that previous session's connection will end, at which point the op will be resubmitted. - ++this.pendingOpCount.value; + assert( + !this.localOpActivity, + "runtime active must be undefined when entering applyStashedOp", + ); + this.localOpActivity = "applyStashed"; + try { + // The op being applied may have been submitted in a previous session, so we increment the count here. + // Either the ack will arrive and be processed, or that previous session's connection will end, at which point the op will be resubmitted. + ++this.pendingOpCount.value; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const type = content?.type as DataStoreMessageType; - switch (type) { - case DataStoreMessageType.Attach: { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const attachMessage = content.content as IAttachMessage; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const type = content?.type as DataStoreMessageType; + switch (type) { + case DataStoreMessageType.Attach: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const attachMessage = content.content as IAttachMessage; - const flatBlobs = new Map(); - const snapshotTree = buildSnapshotTree(attachMessage.snapshot.entries, flatBlobs); + const flatBlobs = new Map(); + const snapshotTree = buildSnapshotTree(attachMessage.snapshot.entries, flatBlobs); - const channelContext = this.createRehydratedLocalChannelContext( - attachMessage.id, - snapshotTree, - flatBlobs, - ); - await channelContext.getChannel(); - this.contexts.set(attachMessage.id, channelContext); - if (this.attachState === AttachState.Detached) { - this.localChannelContextQueue.set(attachMessage.id, channelContext); - } else { - channelContext.makeVisible(); - this.pendingAttach.add(attachMessage.id); + const channelContext = this.createRehydratedLocalChannelContext( + attachMessage.id, + snapshotTree, + flatBlobs, + ); + await channelContext.getChannel(); + this.contexts.set(attachMessage.id, channelContext); + if (this.attachState === AttachState.Detached) { + this.localChannelContextQueue.set(attachMessage.id, channelContext); + } else { + channelContext.makeVisible(); + this.pendingAttach.add(attachMessage.id); + } + return; + } + case DataStoreMessageType.ChannelOp: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const envelope = content.content as IEnvelope; + const channelContext = this.contexts.get(envelope.address); + assert(!!channelContext, 0x184 /* "There should be a channel context for the op" */); + await channelContext.getChannel(); + return channelContext.applyStashedOp(envelope.contents); + } + default: { + unreachableCase(type); } - return; - } - case DataStoreMessageType.ChannelOp: { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const envelope = content.content as IEnvelope; - const channelContext = this.contexts.get(envelope.address); - assert(!!channelContext, 0x184 /* "There should be a channel context for the op" */); - await channelContext.getChannel(); - return channelContext.applyStashedOp(envelope.contents); - } - default: { - unreachableCase(type); } + } finally { + this.localOpActivity = undefined; } } diff --git a/packages/runtime/datastore/src/test/types/validateDatastorePrevious.generated.ts b/packages/runtime/datastore/src/test/types/validateDatastorePrevious.generated.ts index 1e149592219a..d4e40f15a220 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> /* From 26c80c398a2d389dc0951a60b13427225ad3fe9a Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Wed, 29 Oct 2025 13:12:19 -0700 Subject: [PATCH 2/5] Update packages/runtime/datastore/src/dataStoreRuntime.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/runtime/datastore/src/dataStoreRuntime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/datastore/src/dataStoreRuntime.ts b/packages/runtime/datastore/src/dataStoreRuntime.ts index 28a840f3ce20..0e6e14478efb 100644 --- a/packages/runtime/datastore/src/dataStoreRuntime.ts +++ b/packages/runtime/datastore/src/dataStoreRuntime.ts @@ -1304,7 +1304,7 @@ export class FluidDataStoreRuntime localOpMetadata: unknown, ): void { this.verifyNotClosed(); - assert(!this.localOpActivity, "runtime active must be undefined when entering rollback"); + assert(!this.localOpActivity, "localOpActivity must be undefined when entering rollback"); this.localOpActivity = "rollback"; try { // The op being rolled back was not/will not be submitted, so decrement the count. From 1c519aec31ea8f7f07d21ad2214dd605780a1d21 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Wed, 29 Oct 2025 13:12:25 -0700 Subject: [PATCH 3/5] Update packages/runtime/datastore/src/dataStoreRuntime.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/runtime/datastore/src/dataStoreRuntime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/datastore/src/dataStoreRuntime.ts b/packages/runtime/datastore/src/dataStoreRuntime.ts index 0e6e14478efb..2f517a842fd4 100644 --- a/packages/runtime/datastore/src/dataStoreRuntime.ts +++ b/packages/runtime/datastore/src/dataStoreRuntime.ts @@ -1334,7 +1334,7 @@ export class FluidDataStoreRuntime public async applyStashedOp(content: any): Promise { assert( !this.localOpActivity, - "runtime active must be undefined when entering applyStashedOp", + "localOpActivity must be undefined when entering applyStashedOp", ); this.localOpActivity = "applyStashed"; try { From 5356bec4f555a790eb97fa4e142ef7215b453f26 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Wed, 29 Oct 2025 16:48:27 -0700 Subject: [PATCH 4/5] fix build --- packages/framework/aqueduct/package.json | 3 ++- .../src/test/types/validateAqueductPrevious.generated.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/framework/aqueduct/package.json b/packages/framework/aqueduct/package.json index b841bd8e12a6..370019ad0ebf 100644 --- a/packages/framework/aqueduct/package.json +++ b/packages/framework/aqueduct/package.json @@ -156,7 +156,8 @@ "typeValidation": { "broken": { "Interface_DataObjectFactoryProps": { - "backCompat": false + "backCompat": false, + "forwardCompat": false }, "Interface_IDataObjectProps": { "backCompat": false diff --git a/packages/framework/aqueduct/src/test/types/validateAqueductPrevious.generated.ts b/packages/framework/aqueduct/src/test/types/validateAqueductPrevious.generated.ts index 1a2cd3d9368c..d2ea11038eeb 100644 --- a/packages/framework/aqueduct/src/test/types/validateAqueductPrevious.generated.ts +++ b/packages/framework/aqueduct/src/test/types/validateAqueductPrevious.generated.ts @@ -274,6 +274,7 @@ declare type current_as_old_for_Interface_ContainerRuntimeFactoryWithDefaultData * typeValidation.broken: * "Interface_DataObjectFactoryProps": {"forwardCompat": false} */ +// @ts-expect-error compatibility expected to be broken declare type old_as_current_for_Interface_DataObjectFactoryProps = requireAssignableTo>, TypeOnly>> /* From 759f8b40b01ec0a11a23cde075ca625adeccfb52 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Mon, 10 Nov 2025 11:41:16 -0800 Subject: [PATCH 5/5] Update packages/runtime/datastore-definitions/src/dataStoreRuntime.ts Co-authored-by: Joshua Smithrud <54606601+Josmithr@users.noreply.github.com> --- packages/runtime/datastore-definitions/src/dataStoreRuntime.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/runtime/datastore-definitions/src/dataStoreRuntime.ts b/packages/runtime/datastore-definitions/src/dataStoreRuntime.ts index 132dfe00ab4c..ad5ec92b2b23 100644 --- a/packages/runtime/datastore-definitions/src/dataStoreRuntime.ts +++ b/packages/runtime/datastore-definitions/src/dataStoreRuntime.ts @@ -178,6 +178,7 @@ export interface IFluidDataStoreRuntime /** * Indicates the current local operation activity being performed by the data store runtime. * + * @remarks * This property allows consumers to know when the runtime itself is actively making changes to data store DDSes. * When this property is not `undefined`, consumers should expect to see state modifications initiated by the runtime * rather than by the consumer directly: