Skip to content
Open
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
35 changes: 35 additions & 0 deletions packages/runtime/container-runtime/src/containerRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1326,6 +1326,13 @@ export class ContainerRuntime
// Once it loads, it will process all such ops and we will stop accumulating further ops - ops will be processes as they come in.
private pendingIdCompressorOps: IdCreationRange[] = [];

/**
* Tracks the next expected firstGenCount for each session's IdAllocation ranges.
* Used to detect duplicate (overlapping) IdAllocation ranges from forked containers
* that share the same session ID but submit from different clients.
*/
private readonly finalizedIdAllocationRanges = new Map<string, number>();

// Id Compressor serializes final state (see getPendingLocalState()). As result, it needs to skip all ops that preceeded that state
// (such ops will be marked by Loader layer as savedOp === true)
// That said, in "delayed" mode it's possible that Id Compressor was never initialized before getPendingLocalState() is called.
Expand Down Expand Up @@ -3431,6 +3438,34 @@ export class ContainerRuntime
this.pendingIdCompressorOps.length === 0,
0x979 /* there should be no pending ops! */,
);

// Detect duplicate (overlapping) IdAllocation ranges from forked containers.
// Forked containers share the same session ID but submit from different clients,
// producing IdAllocation ranges with the same sessionId and overlapping genCounts.
// IdAllocation batches don't have a stable batch identity over resubmit so the DuplicateBatchDetector cannot
// catch these duplicates — we must detect them here before finalizeCreationRange.
if (range.ids !== undefined) {
const nextExpectedGenCount = this.finalizedIdAllocationRanges.get(range.sessionId);
if (
nextExpectedGenCount !== undefined &&
range.ids.firstGenCount < nextExpectedGenCount
) {
throw new DataCorruptionError(
"Duplicate IdAllocation range detected - overlapping ID ranges for the same session (likely a container fork)",
{
sessionId: range.sessionId,
firstGenCount: range.ids.firstGenCount,
count: range.ids.count,
nextExpectedGenCount,
},
);
}
this.finalizedIdAllocationRanges.set(
Copy link
Member

Choose a reason for hiding this comment

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

What would it look like to move this duplicate tracking into ID Compressor itself? (Inside finalizeCreationRange)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've been playing with it and by doing so it breaks some other id compressor tests. I think this is a better place to target the forked scenario better, given that finalizeCreationRange is used in different places and id compressor already has other mechanisms to detect similar scenarios.

Copy link
Member

Choose a reason for hiding this comment

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

Ok. Yeah I can see how trying to modify the core finalizeCreationRange fn could be troublesome.

I'm always leery of adding a too much niche logic to this file/class. Can we add a new fn to IIdCompressorCore like tryFinalizeCreationRange that returns status about whether it succeeded (rather than throwing), and includes this check?

That type is legacy-beta so you'd have to add it as optional but I know Taylor wants to move it to internal anyway so we can consider it pretty flexible.

Copy link
Member

Choose a reason for hiding this comment

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

@taylorsw04 can you please review this new logic? Any thoughts on moving it into the ID Compressor impl rather than adding it here?

range.sessionId,
range.ids.firstGenCount + range.ids.count,
);
}

this._idCompressor.finalizeCreationRange(range);
}
}
Expand Down
139 changes: 139 additions & 0 deletions packages/runtime/container-runtime/src/test/containerRuntime.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,145 @@ describe("Runtime", () => {
"All Ids should have been finalized after calling createSummary()",
);
});

describe("Duplicate IdAllocation range detection", () => {
let containerRuntime: ContainerRuntime;
const remoteClientId = "remoteClient1";
const forkClientId = "remoteClient2";
// Use the runtime's own session ID (obtained after creation) for realism,
// but also test with an arbitrary remote session ID.
let localSessionId: string;

beforeEach(async () => {
containerRuntime = await ContainerRuntime.loadRuntime2({
context: getMockContext() as IContainerContext,
registry: new FluidDataStoreRegistry([]),
existing: false,
runtimeOptions: {
enableRuntimeIdCompressor: "on",
},
provideEntryPoint: mockProvideEntryPoint,
});
const compressor = containerRuntime.idCompressor;
assert(compressor !== undefined, "IdCompressor should be available");
localSessionId = compressor.localSessionId;
nextClientSequenceNumber = 1;
});

let nextClientSequenceNumber = 1;

function makeIdAllocationOp(
sessionId: string,
firstGenCount: number,
count: number,
clientId: string,
sequenceNumber: number,
): ISequencedDocumentMessage {
const op: Partial<ISequencedDocumentMessage> = {
contents: JSON.stringify({
type: ContainerMessageType.IdAllocation,
contents: {
sessionId,
ids: {
firstGenCount,
count,
requestedClusterSize: 128,
localIdRanges: [[firstGenCount, count]],
},
},
}),
type: MessageType.Operation,
sequenceNumber,
clientSequenceNumber: nextClientSequenceNumber++,
clientId,
minimumSequenceNumber: 0,
referenceSequenceNumber: 0,
timestamp: Date.now(),
};
return op as ISequencedDocumentMessage;
}

it("Throws DataCorruptionError when overlapping IdAllocation ranges arrive for the same session from different clients", () => {
// First IdAllocation from remoteClient1 — should succeed
containerRuntime.process(
makeIdAllocationOp(localSessionId, 1, 5, remoteClientId, 1),
false /* local */,
);

// Second IdAllocation from forkClient with the SAME session and overlapping range — should throw
assert.throws(
() =>
containerRuntime.process(
makeIdAllocationOp(localSessionId, 1, 5, forkClientId, 2),
false /* local */,
),
(error: IErrorBase) => {
assert(isFluidError(error));
assert.strictEqual(error.errorType, ContainerErrorTypes.dataCorruptionError);
assert(
error.message.includes("Duplicate IdAllocation range detected"),
`Unexpected error message: ${error.message}`,
);
return true;
},
"Processing overlapping IdAllocation ranges for the same session should throw DataCorruptionError",
);
});

it("Allows sequential (non-overlapping) IdAllocation ranges for the same session", () => {
// First range: genCounts 1-5
containerRuntime.process(
makeIdAllocationOp(localSessionId, 1, 5, remoteClientId, 1),
false /* local */,
);

// Second range: genCounts 6-10 (sequential, no overlap)
containerRuntime.process(
makeIdAllocationOp(localSessionId, 6, 5, remoteClientId, 2),
false /* local */,
);
// No error — sequential ranges for the same session are fine (normal reconnection)
});

it("Allows IdAllocation ranges from different sessions", () => {
const otherSessionId = "aaaaaaaa-bbbb-4ccc-dddd-eeeeeeeeeeee";
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
const otherSessionId = "aaaaaaaa-bbbb-4ccc-dddd-eeeeeeeeeeee";
const otherSessionId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";

Haha I'm kidding, it doesn't matter what it is but I thought it was funny the "4" in the middle

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wonder what on earth Claude was thinking writing that 4 mmm


containerRuntime.process(
makeIdAllocationOp(localSessionId, 1, 5, remoteClientId, 1),
false /* local */,
);

// Different session — no conflict
containerRuntime.process(
makeIdAllocationOp(otherSessionId, 1, 5, forkClientId, 2),
false /* local */,
);
// No error — different sessions can independently allocate the same genCount ranges
});

it("Detects partial overlap where new range starts before the end of the previous one", () => {
// First range: genCounts 1-5
containerRuntime.process(
makeIdAllocationOp(localSessionId, 1, 5, remoteClientId, 1),
false /* local */,
);

// Overlapping range starting at genCount 3 (before nextExpected=6)
Copy link
Member

Choose a reason for hiding this comment

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

I wonder if we should make this case an assert - no known way to trigger this case in reality is there?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think so but given I'm no expert on id compressor I wouldn't assert that. Seems only relevant to internal id compressor logic so I wouldn't add that assert in this PR

assert.throws(
() =>
containerRuntime.process(
makeIdAllocationOp(localSessionId, 3, 5, forkClientId, 2),
false /* local */,
),
(error: IErrorBase) => {
assert(isFluidError(error));
assert.strictEqual(error.errorType, ContainerErrorTypes.dataCorruptionError);
return true;
},
"Partially overlapping ranges should be detected as duplicates",
);
});
});
});

describe("Batching", () => {
Expand Down
Loading
Loading