Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@ import type {
IIdCompressor,
OpSpaceCompressedId,
SessionId,
SessionSpaceCompressedId,
} from "@fluidframework/id-compressor";
import { isFinalId } from "@fluidframework/id-compressor/internal";
import { v5 as uuidV5 } from "uuid";

import { DiscriminatedUnionDispatcher } from "../../../codec/index.js";
import type {
Expand All @@ -19,7 +18,12 @@ import type {
Value,
TreeChunk,
} from "../../../core/index.js";
import { assertValidIndex, brand } from "../../../util/index.js";
import {
assertValidIndex,
brand,
decompressIdentifierIfNeeded,
forceDecodeEncodedIdWithoutSession,
} from "../../../util/index.js";
import { BasicChunk } from "../basicChunk.js";
import { emptyChunk } from "../emptyChunk.js";
import { SequenceChunk } from "../sequenceChunk.js";
Expand Down Expand Up @@ -76,12 +80,6 @@ export interface IdDecodingContext {
sharedObjectId?: string;
}

/**
* Random v4 UUID generated as a namespace for the "heal an unresolvable identifier into a stable UUID"
* path in {@link readValue}. This scheme requires consensus across all clients to function.
*/
const healingNamespace = "f8a89df3-6882-400f-b913-4c1f6f0157bd";

/**
* Decode `chunk` into a TreeChunk.
*/
Expand Down Expand Up @@ -148,45 +146,33 @@ export function readValue(
typeof streamValue === "number" || typeof streamValue === "string",
0x997 /* identifier must be string or number. */,
);
if (typeof streamValue === "string") {
return streamValue;
}
const idCompressor = idDecodingContext.idCompressor;
// OpSpaceCompressedIds are negative, and require a session-id to compute their value.
// Due to a bug, we have some special casing for them (see below).
if (
idDecodingContext.isSummary === true &&
!isFinalId(streamValue as OpSpaceCompressedId)
) {
if (
idDecodingContext.healUnresolvableIdentifiersOnDecode === true &&
idDecodingContext.sharedObjectId !== undefined
) {
// Documents written before the encode-side fix for non-finalized identifier
// values can persist negative op-space IDs that are no
// longer resolvable once the originating session's local state has been stripped.
// When loading such a summary with the heal-on-decode option on, synthesize a deterministic
// stable UUID so all readers of the same blob agree on the resulting value.
//
// The heal path is intentionally restricted to summary loads — an
// unresolvable ID encountered while applying an op should still surface as
// an error, since it indicates a real bug rather than a recoverable state.
return uuidV5(
`${idDecodingContext.sharedObjectId}|${streamValue}`,
healingNamespace,
);
}
// See `SharedTreeOptionsBeta.healUnresolvableIdentifiersOnDecode` for details on this error.
throw new Error(
"Summary could not be loaded due incorrectly encoded identifier. See SharedTreeOptionsBeta.healUnresolvableIdentifiersOnDecode for mitigation.",
);
}
return idCompressor.decompress(
idCompressor.normalizeToSessionSpace(
streamValue as OpSpaceCompressedId,
idDecodingContext.originatorId,
),
);

// As a mitigation for a bug in Fluid Framework Client Versions < 2.101.0:
// Documents written before the encode-side fix for non-finalized identifier
// values can persist negative op-space IDs that are no
// longer resolvable once the originating session's local state has been stripped.
// When loading such a summary with the heal-on-decode option on, synthesize a deterministic
// stable UUID so all readers of the same blob agree on the resulting value.
//
// The heal path is intentionally restricted to summary loads — an
// unresolvable ID encountered while applying an op should still surface as
// an error, since it indicates a real bug rather than a recoverable state.
const sessionIdOrString: SessionSpaceCompressedId | string =
typeof streamValue === "string"
? streamValue
: forceDecodeEncodedIdWithoutSession(
streamValue as OpSpaceCompressedId,
idCompressor,
{
enableHealingWorkaround:
idDecodingContext.isSummary === true &&
idDecodingContext.healUnresolvableIdentifiersOnDecode === true &&
idDecodingContext.sharedObjectId !== undefined,
sharedObjectId: idDecodingContext.sharedObjectId ?? "",
},
);
return decompressIdentifierIfNeeded(sessionIdOrString, idCompressor);
} else {
// EncodedCounter case:
unreachableCase(shape, "decoding values as deltas is not yet supported");
Expand Down
262 changes: 262 additions & 0 deletions packages/dds/tree/src/test/util/compressedIds.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
/*!
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
* Licensed under the MIT License.
*/

import { strict as assert } from "node:assert";

import type {
OpSpaceCompressedId,
SessionId,
SessionSpaceCompressedId,
} from "@fluidframework/id-compressor";
import { createIdCompressor, createSessionId } from "@fluidframework/id-compressor/internal";
import { validateAssertionError } from "@fluidframework/test-runtime-utils/internal";

import {
type OriginatorlessEncodedId,
decodeEncodedIdWithOriginator,
decodeOriginatorlessEncodedId,
decompressIdentifierIfNeeded,
forceDecodeEncodedIdWithoutSession,
tryDecodeEncodedIdWithoutSession,
} from "../../util/index.js";
import { testIdCompressor } from "../utils.js";

/**
* Mints an op-space id that is unresolvable by `testIdCompressor` — it was generated
* in a fresh foreign compressor whose session is unknown to `testIdCompressor`, so
* it is non-final and `tryNormalizeToSessionSpaceWithoutSession` returns `undefined`.
*/
function makeUnresolvableOpSpaceId(): {
opSpaceId: OpSpaceCompressedId;
originatorId: SessionId;
} {
const foreignSession = createSessionId();
const foreignCompressor = createIdCompressor(foreignSession);
const sessionSpaceId = foreignCompressor.generateCompressedId();
const opSpaceId = foreignCompressor.normalizeToOpSpace(sessionSpaceId);
return { opSpaceId, originatorId: foreignSession };
}

describe("compressedIds", () => {
describe("decodeOriginatorlessEncodedId", () => {
it("returns a session-space id for a finalized compressed id", () => {
const compressedId = testIdCompressor.generateCompressedId();
const opSpaceId = testIdCompressor.normalizeToOpSpace(compressedId);
// `testIdCompressor` finalizes eagerly, so this is a final id and is
// assignable to `OriginatorlessEncodedId` at runtime.
const result = decodeOriginatorlessEncodedId(
opSpaceId as unknown as OriginatorlessEncodedId,
testIdCompressor,
);
assert.equal(result, compressedId);
});

it("asserts when handed a non-final compressed id at runtime", () => {
const { opSpaceId } = makeUnresolvableOpSpaceId();
assert.throws(
() =>
decodeOriginatorlessEncodedId(
opSpaceId as unknown as OriginatorlessEncodedId,
testIdCompressor,
),
validateAssertionError(/OriginatorlessEncodedId must be a finalized/),
);
});
});

describe("decodeEncodedIdWithOriginator", () => {
it("normalizes a local op-space id back to its session-space form using the originator", () => {
const remoteSession = createSessionId();
const remoteCompressor = createIdCompressor(remoteSession);
const remoteSessionSpaceId = remoteCompressor.generateCompressedId();
const remoteOpSpaceId = remoteCompressor.normalizeToOpSpace(remoteSessionSpaceId);

// The remote op-space id is non-final and unresolvable without the
// originator session. With the correct originator, it normalizes.
const result = decodeEncodedIdWithOriginator(
remoteOpSpaceId,
remoteSession,
remoteCompressor,
);
assert.equal(result, remoteSessionSpaceId);
});

it("returns the same value for a finalized op-space id regardless of the originator passed", () => {
// `testIdCompressor` finalizes eagerly: this id is final.
const compressedId = testIdCompressor.generateCompressedId();
const opSpaceId = testIdCompressor.normalizeToOpSpace(compressedId);
const arbitraryOriginator = createSessionId();
const result = decodeEncodedIdWithOriginator(
opSpaceId,
arbitraryOriginator,
testIdCompressor,
);
assert.equal(result, compressedId);
});
});

describe("tryDecodeEncodedIdWithoutSession", () => {
it("returns a session-space id for a finalized compressed id", () => {
const compressedId = testIdCompressor.generateCompressedId();
const opSpaceId = testIdCompressor.normalizeToOpSpace(compressedId);
const result = tryDecodeEncodedIdWithoutSession(opSpaceId, testIdCompressor);
assert.equal(result, compressedId);
});

it("returns undefined for a non-final compressed id", () => {
const { opSpaceId } = makeUnresolvableOpSpaceId();
const result = tryDecodeEncodedIdWithoutSession(opSpaceId, testIdCompressor);
assert.equal(result, undefined);
});
});

describe("forceDecodeEncodedIdWithoutSession", () => {
const sharedObjectId = "doc-a";

it("returns a session-space id for a finalized op-space id", () => {
const compressedId = testIdCompressor.generateCompressedId();
const opSpaceId = testIdCompressor.normalizeToOpSpace(compressedId);
const result = forceDecodeEncodedIdWithoutSession(opSpaceId, testIdCompressor, {
enableHealingWorkaround: false,
});
assert.equal(result, compressedId);
});

it("throws on a non-final op-space id when healing is disabled", () => {
const { opSpaceId } = makeUnresolvableOpSpaceId();
assert.throws(
() =>
forceDecodeEncodedIdWithoutSession(opSpaceId, testIdCompressor, {
enableHealingWorkaround: false,
}),
/Summary could not be loaded due incorrectly encoded identifier/,
);
});

it("synthesizes a deterministic UUIDv5 on a non-final op-space id when healing is enabled", () => {
const { opSpaceId } = makeUnresolvableOpSpaceId();
const result = forceDecodeEncodedIdWithoutSession(opSpaceId, testIdCompressor, {
enableHealingWorkaround: true,
sharedObjectId,
});
assert.equal(typeof result, "string");
// Spot-check the exact value to lock in the v5 derivation — every client
// loading the same blob must compute the same UUID for consensus.
assert.equal(result, "d5d534e7-5e2c-53c3-b26c-9fd81e6fbc37");
});

it("produces the same UUID for the same (sharedObjectId, opSpaceId) inputs", () => {
const { opSpaceId } = makeUnresolvableOpSpaceId();
const heal = (): unknown =>
forceDecodeEncodedIdWithoutSession(opSpaceId, testIdCompressor, {
enableHealingWorkaround: true,
sharedObjectId,
});
assert.equal(heal(), heal());
});

it("produces different UUIDs for different sharedObjectIds", () => {
const { opSpaceId } = makeUnresolvableOpSpaceId();
const heal = (sid: string): unknown =>
forceDecodeEncodedIdWithoutSession(opSpaceId, testIdCompressor, {
enableHealingWorkaround: true,
sharedObjectId: sid,
});
assert.notEqual(heal("doc-a"), heal("doc-b"));
});

it("produces different UUIDs for different op-space ids", () => {
const foreignSession = createSessionId();
const foreignCompressor = createIdCompressor(foreignSession);
const opSpaceA = foreignCompressor.normalizeToOpSpace(
foreignCompressor.generateCompressedId(),
);
const opSpaceB = foreignCompressor.normalizeToOpSpace(
foreignCompressor.generateCompressedId(),
);
assert.notEqual(opSpaceA, opSpaceB);
const heal = (id: OpSpaceCompressedId): unknown =>
forceDecodeEncodedIdWithoutSession(id, testIdCompressor, {
enableHealingWorkaround: true,
sharedObjectId,
});
assert.notEqual(heal(opSpaceA), heal(opSpaceB));
});

it("does not invoke the heal path when the id is resolvable, even with healing enabled", () => {
const compressedId = testIdCompressor.generateCompressedId();
const opSpaceId = testIdCompressor.normalizeToOpSpace(compressedId);
const result = forceDecodeEncodedIdWithoutSession(opSpaceId, testIdCompressor, {
enableHealingWorkaround: true,
sharedObjectId,
});
// The id is final, so the result is a session-space id (numeric), not a v5 UUID string.
assert.equal(result, compressedId);
assert.equal(typeof result, "number");
});

it("accepts the discriminated-union shape with no sharedObjectId when healing is disabled", () => {
// The options union allows `sharedObjectId` to be omitted when
// `enableHealingWorkaround: false`, since it is unused in that branch.
const compressedId = testIdCompressor.generateCompressedId();
const opSpaceId = testIdCompressor.normalizeToOpSpace(compressedId);
const result = forceDecodeEncodedIdWithoutSession(opSpaceId, testIdCompressor, {
enableHealingWorkaround: false,
});
assert.equal(result, compressedId);
});
});

describe("decompressIdentifierIfNeeded", () => {
it("passes string inputs through unchanged", () => {
const v5RoundTrip = "d5d534e7-5e2c-53c3-b26c-9fd81e6fbc37";
const result = decompressIdentifierIfNeeded(v5RoundTrip, testIdCompressor);
assert.equal(result, v5RoundTrip);
});

it("decompresses a session-space compressed id to its UUID string", () => {
const compressedId = testIdCompressor.generateCompressedId();
const expected = testIdCompressor.decompress(compressedId);
const result = decompressIdentifierIfNeeded(compressedId, testIdCompressor);
assert.equal(result, expected);
assert.equal(typeof result, "string");
});
});

describe("module shape", () => {
// Type-only assertions to lock in the documented signature shapes. These
// have no runtime cost and exist to catch regressions where someone
// narrows or widens a helper's signature accidentally.
it("helper return types match the documented arms", () => {
const compressedId = testIdCompressor.generateCompressedId();
const opSpaceId = testIdCompressor.normalizeToOpSpace(compressedId);

const a: SessionSpaceCompressedId = decodeOriginatorlessEncodedId(
opSpaceId as unknown as OriginatorlessEncodedId,
testIdCompressor,
);
const b: SessionSpaceCompressedId = decodeEncodedIdWithOriginator(
opSpaceId,
testIdCompressor.localSessionId,
testIdCompressor,
);
const c: SessionSpaceCompressedId | undefined = tryDecodeEncodedIdWithoutSession(
opSpaceId,
testIdCompressor,
);
const d: SessionSpaceCompressedId | string = forceDecodeEncodedIdWithoutSession(
opSpaceId,
testIdCompressor,
{ enableHealingWorkaround: false },
);
const e: string = decompressIdentifierIfNeeded(compressedId, testIdCompressor);
assert(a !== undefined);
assert(b !== undefined);
assert(c !== undefined);
assert(d !== undefined);
assert(e !== undefined);
});
});
});
Loading