diff --git a/packages/runtime/container-runtime/api-report/container-runtime.legacy.beta.api.md b/packages/runtime/container-runtime/api-report/container-runtime.legacy.beta.api.md index 4fe44106fbff..8ede8c8fa7d8 100644 --- a/packages/runtime/container-runtime/api-report/container-runtime.legacy.beta.api.md +++ b/packages/runtime/container-runtime/api-report/container-runtime.legacy.beta.api.md @@ -37,6 +37,7 @@ export interface ContainerRuntimeOptions { readonly chunkSizeInBytes: number; readonly compressionOptions: ICompressionRuntimeOptions; readonly createBlobPayloadPending: true | undefined; + readonly disableSchemaUpgrade: boolean; // @deprecated readonly enableGroupedBatching: boolean; readonly enableRuntimeIdCompressor: IdCompressorMode; diff --git a/packages/runtime/container-runtime/package.json b/packages/runtime/container-runtime/package.json index c238073bc5e9..8192355e44d5 100644 --- a/packages/runtime/container-runtime/package.json +++ b/packages/runtime/container-runtime/package.json @@ -219,7 +219,11 @@ "typescript": "~5.4.5" }, "typeValidation": { - "broken": {}, + "broken": { + "Interface_ContainerRuntimeOptions": { + "forwardCompat": false + } + }, "entrypoint": "legacy" } } diff --git a/packages/runtime/container-runtime/src/containerCompatibility.ts b/packages/runtime/container-runtime/src/containerCompatibility.ts index 638f5ae5e8b4..291106a0ea56 100644 --- a/packages/runtime/container-runtime/src/containerCompatibility.ts +++ b/packages/runtime/container-runtime/src/containerCompatibility.ts @@ -43,6 +43,7 @@ export type RuntimeOptionsAffectingDocSchema = Omit< | "maxBatchSizeInBytes" | "loadSequenceNumberVerification" | "summaryOptions" + | "disableSchemaUpgrade" >; /** diff --git a/packages/runtime/container-runtime/src/containerRuntime.ts b/packages/runtime/container-runtime/src/containerRuntime.ts index 7e15fc79e85a..7d2ba9dc3a91 100644 --- a/packages/runtime/container-runtime/src/containerRuntime.ts +++ b/packages/runtime/container-runtime/src/containerRuntime.ts @@ -474,6 +474,13 @@ export interface ContainerRuntimeOptions { * When enabled (`true`), createBlob will return a handle before the blob upload completes. */ readonly createBlobPayloadPending: true | undefined; + + /** + * When this property is set to true, the runtime will never send DocumentSchemaChange ops + * and will throw an error if any incoming DocumentSchemaChange ops are received. + * This effectively freezes the document schema at whatever state it was in when the document was created. + */ + readonly disableSchemaUpgrade: boolean; } /** @@ -960,6 +967,7 @@ export class ContainerRuntime loadSequenceNumberVerification: "close", maxBatchSizeInBytes: defaultMaxBatchSizeInBytes, chunkSizeInBytes: defaultChunkSizeInBytes, + disableSchemaUpgrade: false, }; const defaultConfigs = { @@ -985,6 +993,7 @@ export class ContainerRuntime ? disabledCompressionConfig : defaultConfigs.compressionOptions, createBlobPayloadPending = defaultConfigs.createBlobPayloadPending, + disableSchemaUpgrade = defaultConfigs.disableSchemaUpgrade, }: IContainerRuntimeOptionsInternal = runtimeOptions; // If explicitSchemaControl is off, ensure that options which require explicitSchemaControl are not enabled. @@ -1183,6 +1192,7 @@ export class ContainerRuntime }, { minVersionForCollab }, logger, + disableSchemaUpgrade, ); // If the minVersionForCollab for this client is greater than the existing one, we should use that one going forward. @@ -1213,6 +1223,7 @@ export class ContainerRuntime enableGroupedBatching, explicitSchemaControl, createBlobPayloadPending, + disableSchemaUpgrade, }; validateMinimumVersionForCollab(updatedMinVersionForCollab); diff --git a/packages/runtime/container-runtime/src/summary/documentSchema.ts b/packages/runtime/container-runtime/src/summary/documentSchema.ts index 53df435a505c..42e9831a9d2d 100644 --- a/packages/runtime/container-runtime/src/summary/documentSchema.ts +++ b/packages/runtime/container-runtime/src/summary/documentSchema.ts @@ -512,7 +512,7 @@ function arrayToProp(arr: string[]): string[] | undefined { * * Users of this class need to use DocumentsSchemaController.sessionSchema to determine what features can be used. * - * There are two modes this class can operate: + * There are three modes this class can operate: * 1) Legacy mode. In such mode it does not issue any ops to change document schema. Any changes happen implicitly, * right away, and new features are available right away * 2) Non-legacy mode. In such mode any changes to schema require an op roundtrip. This class will manage such transitions. @@ -523,6 +523,9 @@ function arrayToProp(arr: string[]): string[] | undefined { * then eventually all documents that are modified will have that feature reflected in their schema. It could require * multiple reloads / new sessions to get there (depends on if code reacts to schema changes right away, or only consults * schema on document load). + * 3) Schema upgrade disabled mode (disableSchemaUpgrade = true). In this mode the controller will never send DocumentSchemaChange ops + * and will throw an error if any incoming schema change ops are received. The document schema is effectively frozen at the schema + * loaded for this session (snapshot) and will not accept further schema-change ops. * * How schemas are changed (in non-legacy mode): * If a client needs to change a schema, it will attempt to do so as part of normal ops sending process. @@ -569,6 +572,7 @@ export class DocumentsSchemaController { * @param onSchemaChange - callback that is called whenever schema is changed (not called on creation / load, only when processing document schema change ops) * @param info - Informational properties of the document that are not subject to strict schema enforcement * @param logger - telemetry logger from the runtime + * @param disableSchemaUpgrade - when true, the controller will never send or accept DocumentSchemaChange ops */ constructor( existing: boolean, @@ -578,6 +582,7 @@ export class DocumentsSchemaController { private readonly onSchemaChange: (schema: IDocumentSchemaCurrent) => void, info: IDocumentSchemaInfo, logger: ITelemetryLoggerExt, + private readonly disableSchemaUpgrade: boolean, ) { // For simplicity, let's only support new schema features for explicit schema control mode assert( @@ -704,9 +709,12 @@ export class DocumentsSchemaController { * Called by Container runtime whenever it is about to send some op. * It gives opportunity for controller to issue its own ops - we do not want to send ops if there are no local changes in document. * Please consider note above constructor about race conditions - current design is to generate op only once in a session lifetime. - * @returns Optional message to send. + * @returns Optional message to send. Always returns undefined when disableSchemaUpgrade is true. */ public maybeGenerateSchemaMessage(): IDocumentSchemaChangeMessageOutgoing | undefined { + if (this.disableSchemaUpgrade) { + return undefined; + } if (this.futureSchema !== undefined && !this.opPending) { this.opPending = true; assert( @@ -739,6 +747,7 @@ export class DocumentsSchemaController { /** * Process document schema change messages * Called by ContainerRuntime whenever it sees document schema messages. + * When disableSchemaUpgrade is true, an error is thrown if any incoming schema change ops are received. * @param contents - contents of the messages * @param local - whether op is local * @param sequenceNumber - sequence number of the op @@ -749,6 +758,20 @@ export class DocumentsSchemaController { local: boolean, sequenceNumber: number, ): boolean { + if (this.disableSchemaUpgrade) { + assert( + !local, + "local schema change messages should never be generated when disableSchemaUpgrade is enabled", + ); + // Clients with disableSchemaUpgrade enabled should never generate schema change messages, but they + // may receive them from misconfigured clients. In such case, throw on any incoming schema change ops + // to prevent unexpected schema upgrades. + throw DataProcessingError.create( + "DocSchema: Received schema change op while disableSchemaUpgrade is enabled", + "processDocumentSchemaMessages", + undefined, + ); + } for (const content of contents) { this.validateSeqNumber(content.refSeq, this.documentSchema.refSeq, "content.refSeq"); this.validateSeqNumber(this.documentSchema.refSeq, sequenceNumber, "refSeq"); diff --git a/packages/runtime/container-runtime/src/test/containerRuntime.spec.ts b/packages/runtime/container-runtime/src/test/containerRuntime.spec.ts index a30ab00997a6..7a05e96d18d5 100644 --- a/packages/runtime/container-runtime/src/test/containerRuntime.spec.ts +++ b/packages/runtime/container-runtime/src/test/containerRuntime.spec.ts @@ -1800,6 +1800,7 @@ describe("Runtime", () => { enableGroupedBatching: true, // Redundant, but makes the JSON.stringify yield the same result as the logs explicitSchemaControl: false, createBlobPayloadPending: undefined, + disableSchemaUpgrade: false, } as const satisfies ContainerRuntimeOptionsInternal; const mergedRuntimeOptions = { ...defaultRuntimeOptions, ...runtimeOptions } as const; @@ -3758,6 +3759,7 @@ describe("Runtime", () => { enableRuntimeIdCompressor: undefined, enableGroupedBatching: true, explicitSchemaControl: false, + disableSchemaUpgrade: false, }; logger.assertMatchAny([ @@ -3817,6 +3819,7 @@ describe("Runtime", () => { enableRuntimeIdCompressor: undefined, enableGroupedBatching: false, explicitSchemaControl: false, + disableSchemaUpgrade: false, }; logger.assertMatchAny([ @@ -3855,6 +3858,7 @@ describe("Runtime", () => { enableRuntimeIdCompressor: undefined, enableGroupedBatching: true, explicitSchemaControl: false, + disableSchemaUpgrade: false, }; logger.assertMatchAny([ @@ -3893,6 +3897,7 @@ describe("Runtime", () => { enableRuntimeIdCompressor: undefined, enableGroupedBatching: true, explicitSchemaControl: true, + disableSchemaUpgrade: false, }; logger.assertMatchAny([ @@ -3930,6 +3935,7 @@ describe("Runtime", () => { enableRuntimeIdCompressor: undefined, enableGroupedBatching: true, explicitSchemaControl: true, + disableSchemaUpgrade: false, }; logger.assertMatchAny([ @@ -3975,6 +3981,7 @@ describe("Runtime", () => { enableRuntimeIdCompressor: "on", enableGroupedBatching: false, explicitSchemaControl: false, + disableSchemaUpgrade: false, }; logger.assertMatchAny([ @@ -4006,6 +4013,7 @@ describe("Runtime", () => { enableGroupedBatching: undefined, compressionOptions: undefined, explicitSchemaControl: undefined, + disableSchemaUpgrade: undefined, createBlobPayloadPending: undefined, }, }, @@ -4034,6 +4042,7 @@ describe("Runtime", () => { enableRuntimeIdCompressor: undefined, // idCompressor is undefined, since that represents a logical state (off) enableGroupedBatching: true, explicitSchemaControl: false, + disableSchemaUpgrade: false, }; logger.assertMatchAny([ @@ -4074,6 +4083,7 @@ describe("Runtime", () => { enableRuntimeIdCompressor: undefined, enableGroupedBatching: true, explicitSchemaControl: true, + disableSchemaUpgrade: false, }; logger.assertMatchAny([ diff --git a/packages/runtime/container-runtime/src/test/documentSchema.spec.ts b/packages/runtime/container-runtime/src/test/documentSchema.spec.ts index 0a4d1ae64bbb..9924a14acf10 100644 --- a/packages/runtime/container-runtime/src/test/documentSchema.spec.ts +++ b/packages/runtime/container-runtime/src/test/documentSchema.spec.ts @@ -69,6 +69,7 @@ describe("Runtime", () => { () => {}, // onSchemaChange { minVersionForCollab: defaultMinVersionForCollab }, // info, logger, + false, ); } @@ -142,6 +143,7 @@ describe("Runtime", () => { () => {}, // onSchemaChange { minVersionForCollab: defaultMinVersionForCollab }, // info, logger, + false, ); assert(controller.sessionSchema.runtime.disallowedVersions === undefined); @@ -179,6 +181,7 @@ describe("Runtime", () => { () => {}, { minVersionForCollab: defaultMinVersionForCollab }, // info, logger, + false, ); assert.deepEqual(controller.sessionSchema.runtime.disallowedVersions, ["aaa", "bbb"]); let message = controller.maybeGenerateSchemaMessage(); @@ -205,6 +208,8 @@ describe("Runtime", () => { () => {}, { minVersionForCollab: defaultMinVersionForCollab }, // info, logger, + + false, ); assert.deepEqual(controller2.sessionSchema.runtime.disallowedVersions, [ "aaa", @@ -235,6 +240,7 @@ describe("Runtime", () => { () => {}, { minVersionForCollab: defaultMinVersionForCollab }, // info, logger, + false, ); controller3.processDocumentSchemaMessages( [message], @@ -280,6 +286,7 @@ describe("Runtime", () => { () => assert(false, "no schema changes!"), // onSchemaChange { minVersionForCollab: defaultMinVersionForCollab }, // info, logger, + false, ); assert(controller.sessionSchema.refSeq === 0, "refSeq"); @@ -399,6 +406,7 @@ describe("Runtime", () => { () => {}, // onSchemaChange schema.info ?? { minVersionForCollab: defaultMinVersionForCollab }, // info, logger, + false, ); controller.pendingOpNotAcked(); @@ -496,6 +504,7 @@ describe("Runtime", () => { () => {}, // onSchemaChange { minVersionForCollab: defaultMinVersionForCollab }, // info logger, + false, ); const message = controller.maybeGenerateSchemaMessage(); @@ -531,6 +540,7 @@ describe("Runtime", () => { () => {}, // onSchemaChange { minVersionForCollab: defaultMinVersionForCollab }, // info logger, + false, ); // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access -- Accessing private property @@ -590,6 +600,7 @@ describe("Runtime", () => { }, // onSchemaChange { minVersionForCollab: defaultMinVersionForCollab }, // info logger, + false, ); assert(controller.maybeGenerateSchemaMessage() === undefined); @@ -608,6 +619,7 @@ describe("Runtime", () => { }, // onSchemaChange { minVersionForCollab: defaultMinVersionForCollab }, // info logger, + false, ); assert(controller2.maybeGenerateSchemaMessage() === undefined); @@ -632,6 +644,7 @@ describe("Runtime", () => { }, // onSchemaChange { minVersionForCollab: defaultMinVersionForCollab }, // info logger, + false, ); // setting is not on yet @@ -672,6 +685,7 @@ describe("Runtime", () => { () => (schemaChanged = true), // onSchemaChange { minVersionForCollab: defaultMinVersionForCollab }, // info logger, + false, ); controller4.processDocumentSchemaMessages( [message], @@ -701,6 +715,7 @@ describe("Runtime", () => { () => {}, // onSchemaChange { minVersionForCollab: defaultMinVersionForCollab }, // info logger, + false, ); const event = logger.events().find((e) => e.eventName === "MinVersionForCollabWarning"); assert.strictEqual(event, undefined, "telemetry warning event should not be logged"); @@ -714,6 +729,7 @@ describe("Runtime", () => { () => {}, // onSchemaChange { minVersionForCollab: defaultMinVersionForCollab }, // info logger, + false, ); const event2 = logger.events().find((e) => e.eventName === "MinVersionForCollabWarning"); assert.strictEqual(event2, undefined, "telemetry warning event should not be logged"); @@ -733,6 +749,7 @@ describe("Runtime", () => { () => {}, // onSchemaChange { minVersionForCollab: documentMinVersionForCollab }, // info logger, + false, ); const expectedEvent = { category: "generic", @@ -743,6 +760,62 @@ describe("Runtime", () => { assert.deepStrictEqual(event, expectedEvent, "telemetry warning event should be logged"); }); + describe("disableSchemaUpgrade = true", () => { + it("does not send schema change ops", () => { + const controller = new DocumentsSchemaController( + true, // existing + 0, // snapshotSequenceNumber + { ...validConfig, runtime: { ...validConfig.runtime, explicitSchemaControl: true } }, + { ...features, opGroupingEnabled: true }, + () => {}, // onSchemaChange + { minVersionForCollab: defaultMinVersionForCollab }, + logger, + true, // disableSchemaUpgrade + ); + + assert.strictEqual( + controller.maybeGenerateSchemaMessage(), + undefined, + "should not generate schema change message when disableSchemaUpgrade is true", + ); + }); + + it("throws on incoming schema change ops", () => { + const controller = new DocumentsSchemaController( + true, // existing + 0, // snapshotSequenceNumber + { + ...validConfig, + runtime: { + ...validConfig.runtime, + explicitSchemaControl: true, + opGroupingEnabled: undefined, + }, + }, + features, + () => {}, // onSchemaChange + { minVersionForCollab: defaultMinVersionForCollab }, + logger, + true, // disableSchemaUpgrade + ); + + assert.throws( + () => + controller.processDocumentSchemaMessages( + [ + { + ...validConfig, + runtime: { ...validConfig.runtime, opGroupingEnabled: true }, + }, + ], + false, // local + 100, // sequenceNumber + ), + "schema change op while disableSchemaUpgrade is enabled", + ); + }); + }); + /** * Helper function for testing {@link DocumentsSchemaController} instantiation with * an existing schema and requested `minVersionForCollab` through to update @@ -783,6 +856,7 @@ describe("Runtime", () => { onSchemaChange, { minVersionForCollab: newMinVersionForCollab }, logger, + false, ); const message = controller.maybeGenerateSchemaMessage(); diff --git a/packages/runtime/container-runtime/src/test/types/validateContainerRuntimePrevious.generated.ts b/packages/runtime/container-runtime/src/test/types/validateContainerRuntimePrevious.generated.ts index 47708f93005c..1df9e3196b37 100644 --- a/packages/runtime/container-runtime/src/test/types/validateContainerRuntimePrevious.generated.ts +++ b/packages/runtime/container-runtime/src/test/types/validateContainerRuntimePrevious.generated.ts @@ -96,6 +96,7 @@ declare type current_as_old_for_Function_loadContainerRuntime = requireAssignabl * typeValidation.broken: * "Interface_ContainerRuntimeOptions": {"forwardCompat": false} */ +// @ts-expect-error compatibility expected to be broken declare type old_as_current_for_Interface_ContainerRuntimeOptions = requireAssignableTo, TypeOnly> /* diff --git a/packages/service-clients/azure-client/src/test/AzureClient.spec.ts b/packages/service-clients/azure-client/src/test/AzureClient.spec.ts index 5c9de687f602..67e90a040afc 100644 --- a/packages/service-clients/azure-client/src/test/AzureClient.spec.ts +++ b/packages/service-clients/azure-client/src/test/AzureClient.spec.ts @@ -415,6 +415,7 @@ for (const compatibilityMode of ["1", "2"] as const) { enableGroupedBatching: false, explicitSchemaControl: false, createBlobPayloadPending: undefined, + disableSchemaUpgrade: false, } as const satisfies ContainerRuntimeOptionsInternal; const expectedRuntimeOptions2 = { summaryOptions: {}, @@ -431,6 +432,7 @@ for (const compatibilityMode of ["1", "2"] as const) { enableGroupedBatching: true, explicitSchemaControl: true, createBlobPayloadPending: undefined, + disableSchemaUpgrade: false, } as const satisfies ContainerRuntimeOptionsInternal; const expectedRuntimeOptions = diff --git a/packages/test/test-service-load/src/optionsMatrix.ts b/packages/test/test-service-load/src/optionsMatrix.ts index 61ad2c87f5c6..bd43d393e62b 100644 --- a/packages/test/test-service-load/src/optionsMatrix.ts +++ b/packages/test/test-service-load/src/optionsMatrix.ts @@ -118,6 +118,7 @@ export function generateRuntimeOptions( enableGroupedBatching: [true, false], createBlobPayloadPending: [true, undefined], explicitSchemaControl: [true, false], + disableSchemaUpgrade: [true, false], }; const pairwiseOptions = generatePairwiseOptions(