From c141675b5479e2db765cbc859069ea3db0bcf193 Mon Sep 17 00:00:00 2001 From: Kian Thompson <102998837+kian-thompson@users.noreply.github.com> Date: Fri, 15 May 2026 22:55:17 +0000 Subject: [PATCH 1/5] feat(runtime): add API surface for race-id channel create (alternative to claims) Adds the public API surface for race-id-tagged channel creation: - IAttachMessage gains optional 'raceId' field (only emitted when document schema indicates support; older clients in mixed sessions never see it). - IFluidDataStoreRuntime.createChannel gains a new 3-argument overload: createChannel(raceId, type, { onLost }). The overload is the opt-in for race semantics; existing 2-arg call sites are unchanged. - New OnRaceLost type and 'raceResolved' event. Implementation follows in subsequent commits. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/dataStoreRuntime.ts | 70 +++++++++++++++++++ .../datastore-definitions/src/index.ts | 1 + .../runtime-definitions/src/protocol.ts | 16 +++++ 3 files changed, 87 insertions(+) diff --git a/packages/runtime/datastore-definitions/src/dataStoreRuntime.ts b/packages/runtime/datastore-definitions/src/dataStoreRuntime.ts index 0918a49aec34..134d6ffb53de 100644 --- a/packages/runtime/datastore-definitions/src/dataStoreRuntime.ts +++ b/packages/runtime/datastore-definitions/src/dataStoreRuntime.ts @@ -24,6 +24,22 @@ import type { import type { IChannel } from "./channel.js"; +/** + * Callback invoked on the losing client after a channel-creation "race" + * resolves. The runtime schedules this asynchronously after the current op + * processing step; it does not block op processing. + * + * @param loser - The local channel that lost the race. This channel's context + * has been removed from the runtime; the consumer should stop using it after + * this callback returns. The callback should read any state from `loser` and + * apply it to the winner via `runtime.getChannel(winnerChannelId)`. + * @param winnerChannelId - The id of the winning channel. Use + * `IFluidDataStoreRuntime.getChannel` to obtain a handle to it. + * + * @alpha + */ +export type OnRaceLost = (loser: IChannel, winnerChannelId: string) => void; + /** * Events emitted by {@link IFluidDataStoreRuntime}. * @legacy @beta @@ -41,6 +57,21 @@ export interface IFluidDataStoreRuntimeEvents extends IEvent { * The isReadOnly param will express the new readonly state. */ (event: "readonly", listener: (isReadOnly: boolean) => void); + + /** + * Fired after a "race" between concurrent channel creations resolves + * deterministically across all clients. See `createChannel`'s race overload. + * + * @alpha + */ + ( + event: "raceResolved", + listener: (info: { + raceId: string; + winnerChannelId: string; + loserChannelIds: readonly string[]; + }) => void, + ); } /** @@ -109,6 +140,45 @@ export interface IFluidDataStoreRuntime */ createChannel(id: string | undefined, type: string): IChannel; + /** + * Creates a new channel that participates in a first-attach-wins "race" + * with concurrent creations on other clients. + * + * @remarks + * All clients calling this overload with the same `raceId` converge on a + * single attached channel: the first attach op sequenced for a given + * `raceId` wins, and every other client's locally-created channel becomes + * a "loser" whose subsequent ops are dropped deterministically by every + * client. The losing client may register an `onLost` callback to merge + * its local state into the winner. + * + * Each racing client receives its own locally-unique channel id; only the + * `raceId` is shared across clients. Use `IChannel.id` on the returned + * channel for local routing. + * + * Throws a `UsageError` if: + * - The document schema has not enabled the race-id channel-create feature. + * - The data store is detached or in staging mode. + * - This client has already created a racing channel with the same `raceId`. + * + * `onLost` is invoked asynchronously (after the current op processing + * step) on the losing client; it does not block op processing. If `onLost` + * is not provided and this client loses, the loser context is silently + * removed and a telemetry event is fired. + * + * @param raceId - Identifier shared across racing clients. + * @param type - Type of the channel. + * @param raceOptions - Race semantics opt-in. Presence of this argument + * marks the call as a race participant. + * + * @alpha + */ + createChannel( + raceId: string, + type: string, + raceOptions: { onLost?: OnRaceLost }, + ): IChannel; + /** * Adds an existing channel to the data store. * diff --git a/packages/runtime/datastore-definitions/src/index.ts b/packages/runtime/datastore-definitions/src/index.ts index b986216a6a0e..2e72ee6c4266 100644 --- a/packages/runtime/datastore-definitions/src/index.ts +++ b/packages/runtime/datastore-definitions/src/index.ts @@ -23,6 +23,7 @@ export type { IFluidDataStoreRuntimeAlpha, IFluidDataStoreRuntimeEvents, IFluidDataStoreRuntimeInternalConfig, + OnRaceLost, IDeltaManagerErased, } from "./dataStoreRuntime.js"; export type { diff --git a/packages/runtime/runtime-definitions/src/protocol.ts b/packages/runtime/runtime-definitions/src/protocol.ts index d2159b428d2f..8eb711ddff16 100644 --- a/packages/runtime/runtime-definitions/src/protocol.ts +++ b/packages/runtime/runtime-definitions/src/protocol.ts @@ -56,6 +56,22 @@ export interface IAttachMessage { * Initial snapshot of the document (contains ownership) */ snapshot: ITree; + + /** + * Optional identifier used to converge a "race" between multiple clients + * concurrently creating a channel that should resolve to a single instance. + * + * When present, all clients deterministically accept the first attach + * message observed for a given `raceId` and drop subsequent attach messages + * with the same `raceId`. Channel ops addressed to a losing channel id are + * also dropped. See `IFluidDataStoreRuntime.createChannel`'s race overload. + * + * This field is only written when the document schema indicates that all + * collaborators understand it; older clients in mixed sessions never see it. + * + * @alpha + */ + raceId?: string; } /** From adaf797c2e423af4242e45e673d038583e7eb4e9 Mon Sep 17 00:00:00 2001 From: Kian Thompson <102998837+kian-thompson@users.noreply.github.com> Date: Fri, 15 May 2026 23:07:56 +0000 Subject: [PATCH 2/5] feat(datastore): implement race-id DDS create FWW resolution Core race resolution in FluidDataStoreRuntime: - createChannel(raceId, type, raceOptions) overload mints unique internal channel id (${raceId}#${guid}) and tracks the entry. - Outbound IAttachMessage now carries optional raceId. - processAttachMessages applies first-wins resolution across clients: loser context is removed from contexts; loser->winner redirect is recorded; onLost callback is scheduled via queueMicrotask. - Inbound channel ops to known-loser channel ids are dropped on every client via the loserToWinner map, keeping resolution deterministic. - raceResolved event emitted for diagnostics. v1 scope: summary persistence of redirects, doc-schema gate, tests, and changeset still pending per plan.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../runtime/datastore/src/dataStoreRuntime.ts | 227 +++++++++++++++++- 1 file changed, 224 insertions(+), 3 deletions(-) diff --git a/packages/runtime/datastore/src/dataStoreRuntime.ts b/packages/runtime/datastore/src/dataStoreRuntime.ts index da14c2438e5d..1c8876f03202 100644 --- a/packages/runtime/datastore/src/dataStoreRuntime.ts +++ b/packages/runtime/datastore/src/dataStoreRuntime.ts @@ -29,6 +29,7 @@ import type { IFluidDataStoreRuntime, IFluidDataStoreRuntimeEvents, IDeltaManagerErased, + OnRaceLost, } from "@fluidframework/datastore-definitions/internal"; import { type IClientDetails, @@ -250,6 +251,12 @@ function initializePendingOpCount(): { value: number } { }; } +interface RaceEntry { + localChannelId: string; + onLost?: OnRaceLost; + status: "racing" | "resolved"; +} + /** * Base data store class * @legacy @beta @@ -332,6 +339,27 @@ export class FluidDataStoreRuntime private readonly contexts = new Map(); private readonly pendingAttach = new Set(); + /** + * Tracks locally-initiated channel "races" by race id. Used to resolve + * concurrent createChannel calls deterministically across clients. + * + * See `createChannel`'s race overload. + * + * @remarks + * v1 design note: this is in-memory only. On reload, the resolution state + * is reconstructed from `loserToWinner` (rehydrated from summary). + */ + private readonly raceEntries = new Map(); + + /** + * Loser channel id -> winner channel id. Channel ops addressed to any + * loser channel id are dropped deterministically by every client. + * + * Persisted in the `.races` summary blob (see TODO in `summarize`) so that + * mid-session joiners drop late ops to historical losers. + */ + private readonly loserToWinner = new Map(); + private readonly deferredAttached = new Deferred(); private readonly localChannelContextQueue = new Map(); private readonly notBoundedChannelContextSet = new Set(); @@ -673,10 +701,45 @@ export class FluidDataStoreRuntime this.identifyLocalChangeInSummarizer("DDSCreatedInSummarizer", id, type); } - public createChannel(idArg: string | undefined, type: string): IChannel { + public createChannel( + idArg: string | undefined, + type: string, + raceOptions?: { onLost?: OnRaceLost }, + ): IChannel { let id: string; - - if (idArg === undefined) { + const raceId: string | undefined = raceOptions !== undefined ? idArg : undefined; + + if (raceOptions !== undefined) { + // Race overload: idArg is the race id, not the channel id. Each + // racing client mints its own channel id; the race id is the + // shared convergence key. + if (idArg === undefined || idArg.length === 0) { + throw new UsageError( + "createChannel race overload requires a non-empty raceId as the first argument", + ); + } + // Detached / staging-mode races are not supported in v1 — the + // race-resolution mechanism is rooted in attach-op sequencing, + // which only happens once the data store is globally visible. + if (this.visibilityState !== VisibilityState.GloballyVisible) { + throw new UsageError( + "createChannel race overload is not supported while the data store is detached", + ); + } + if (this.inStagingMode) { + throw new UsageError( + "createChannel race overload is not supported in staging mode", + ); + } + if (this.raceEntries.has(raceId!)) { + throw new UsageError( + `createChannel race overload: this client has already created a racing channel for raceId "${raceId}"`, + ); + } + // Mint a locally-unique channel id derived from raceId for + // readability. The runtime treats this as an opaque unique id. + id = `${raceId}#${uuid()}`; + } else if (idArg === undefined) { /** * Return uuid if short-ids are explicitly disabled via feature flags. */ @@ -717,11 +780,34 @@ export class FluidDataStoreRuntime const channel = factory.create(this, id); this.createChannelContext(channel); + if (raceId !== undefined) { + const entry: RaceEntry = { + localChannelId: id, + status: "racing", + }; + if (raceOptions?.onLost !== undefined) { + entry.onLost = raceOptions.onLost; + } + this.raceEntries.set(raceId, entry); + } // Channels (DDS) should not be created in summarizer client. this.identifyLocalChangeInSummarizer("DDSCreatedInSummarizer", id, type); return channel; } + /** + * Reverse lookup: channel id -> race id, used when constructing outbound + * attach messages to know if a raceId field should be included. + */ + private getRaceIdForChannel(channelId: string): string | undefined { + for (const [raceId, entry] of this.raceEntries) { + if (entry.localChannelId === channelId && entry.status === "racing") { + return raceId; + } + } + return undefined; + } + private createChannelContext(channel: IChannel): void { this.notBoundedChannelContextSet.add(channel.id); const context = new LocalChannelContext( @@ -921,6 +1007,15 @@ export class FluidDataStoreRuntime return; } + // Race-loser drop: if the target channel id is a known loser of a + // resolved race, drop the bunch. This is the deterministic part — + // every client computes the same drop because loserToWinner is + // derived from the deterministic attach-op sequence order. + if (this.loserToWinner.has(currentAddress)) { + currentMessagesContent = []; + return; + } + // process the last set of channel ops const channelContext = this.contexts.get(currentAddress); assert(!!channelContext, 0xa6b /* Channel context not found */); @@ -958,6 +1053,130 @@ export class FluidDataStoreRuntime for (const { contents } of messagesContent) { const attachMessage = contents as IAttachMessage; const id = attachMessage.id; + const raceId = attachMessage.raceId; + + // Race resolution: when an attach message carries a raceId, the + // first such message wins deterministically across all clients; + // subsequent attaches with the same raceId are dropped, and channel + // ops to the losing channel ids are dropped via loserToWinner. + if (raceId !== undefined) { + const existingEntry = this.raceEntries.get(raceId); + const winnerChannelId = id; + + if (existingEntry !== undefined && existingEntry.status === "resolved") { + // This race already resolved on this client. Drop the message — + // it's a late/duplicate attach for an already-decided race. + // Skip GC processing and skip remote-context creation. + if (local) { + this.pendingAttach.delete(id); + } + continue; + } + + if (existingEntry !== undefined && existingEntry.localChannelId !== id) { + // We were racing locally and we LOST. The incoming attach + // is the winner. + const loserChannelId = existingEntry.localChannelId; + const loserContext = this.contexts.get(loserChannelId); + this.loserToWinner.set(loserChannelId, winnerChannelId); + existingEntry.status = "resolved"; + + // Remove the loser context so subsequent ops are not routed. + this.contexts.delete(loserChannelId); + this.notBoundedChannelContextSet.delete(loserChannelId); + this.localChannelContextQueue.delete(loserChannelId); + if (local) { + this.pendingAttach.delete(loserChannelId); + } + + // Schedule onLost asynchronously — must NOT block op processing. + const onLost = existingEntry.onLost; + if (loserContext !== undefined) { + queueMicrotask(() => { + try { + if (onLost !== undefined) { + // We need a public IChannel handle to the loser. The + // loser context exposes getChannel() lazily; we surface + // it best-effort. If unavailable, telemetry only. + loserContext + .getChannel() + .then((loserChannel) => onLost(loserChannel, winnerChannelId)) + .catch((error) => { + this.logger.sendErrorEvent( + { + eventName: "RaceLostCallbackFailed", + raceId, + loserChannelId, + winnerChannelId, + }, + error, + ); + }); + } else { + this.logger.sendTelemetryEvent({ + eventName: "RaceLostNoCallback", + category: "generic", + raceId, + loserChannelId, + winnerChannelId, + }); + } + } catch (error) { + this.logger.sendErrorEvent( + { + eventName: "RaceLostCallbackFailed", + raceId, + loserChannelId, + winnerChannelId, + }, + error, + ); + } + }); + } + + // Fall through to create the remote context for the winner + // (the winner's id is different from ours, so the standard + // !this.contexts.has(id) assertion below will pass). + } else if (existingEntry !== undefined && existingEntry.localChannelId === id) { + // We were racing locally and we WON. Mark resolved; the + // existing local context is the one and only attached context. + existingEntry.status = "resolved"; + this.emit("raceResolved", { + raceId, + winnerChannelId, + loserChannelIds: [], + }); + if (local) { + assert( + this.pendingAttach.delete(id), + 0xff01 /* "Unexpected attach (local) channel OP for race winner" */, + ); + } + // No GC processing for this attach is needed beyond the local + // channel's existing GC; the channel was already created + // locally. Fall through to skip remote-context creation by + // continuing. + continue; + } else { + // We were not racing locally — this is a foreign attach + // for a race id we never participated in. Treat as a normal + // remote attach by the winner. + this.raceEntries.set(raceId, { + localChannelId: winnerChannelId, + status: "resolved", + }); + } + + this.emit("raceResolved", { + raceId, + winnerChannelId, + loserChannelIds: + existingEntry !== undefined && existingEntry.localChannelId !== id + ? [existingEntry.localChannelId] + : [], + }); + } // We need to process the GC Data for both local and remote attach messages processAttachMessageGCData(attachMessage.snapshot, (nodeId, toPath) => { @@ -1307,10 +1526,12 @@ export class FluidDataStoreRuntime // Attach message needs the summary in ITree format. Convert the ISummaryTree into an ITree. const snapshot = convertSummaryTreeToITree(summarizeResult.summary); + const raceId = this.getRaceIdForChannel(channel.id); const message: IAttachMessage = { id: channel.id, snapshot, type: channel.attributes.type, + ...(raceId !== undefined ? { raceId } : {}), }; this.pendingAttach.add(channel.id); this.submit({ type: DataStoreMessageType.Attach, content: message }); From 68b5d4dbec0b6a5cd3d34461e33e426b0b6e1067 Mon Sep 17 00:00:00 2001 From: Kian Thompson <102998837+kian-thompson@users.noreply.github.com> Date: Fri, 15 May 2026 23:10:06 +0000 Subject: [PATCH 3/5] test(datastore): unit tests for createChannel race overload Covers synchronous validation paths of the new 3-arg createChannel overload: detached-state rejection, derived channel id format (${raceId}#...), duplicate-raceId rejection per client, and empty raceId rejection. Full FWW resolution across two runtimes (inbound a-t-t-a-c-h ops, loser context teardown, onLost scheduling) requires more harness plumbing and is deferred to a follow-up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/test/dataStoreRuntime.spec.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/runtime/datastore/src/test/dataStoreRuntime.spec.ts b/packages/runtime/datastore/src/test/dataStoreRuntime.spec.ts index ed1acbec6a86..c7ca42a0bfd5 100644 --- a/packages/runtime/datastore/src/test/dataStoreRuntime.spec.ts +++ b/packages/runtime/datastore/src/test/dataStoreRuntime.spec.ts @@ -25,6 +25,7 @@ import { MockFluidDataStoreContext, validateAssertionError, } from "@fluidframework/test-runtime-utils/internal"; +import { VisibilityState } from "@fluidframework/runtime-definitions/internal"; import sinon from "sinon"; import { @@ -226,6 +227,57 @@ describe("FluidDataStoreRuntime Tests", () => { "entryPoint was not initialized", ); }); + + describe("createChannel race overload", () => { + function makeAttachedRuntime(): FluidDataStoreRuntime { + dataStoreContext.containerRuntime = { + inStagingMode: false, + } as unknown as IContainerRuntimeBase; + const rt = createRuntime(dataStoreContext, sharedObjectRegistry); + // Force globally-visible state so the race overload is allowed. + (rt as unknown as { visibilityState: VisibilityState }).visibilityState = + VisibilityState.GloballyVisible; + return rt; + } + + it("rejects when data store is detached", () => { + const rt = createRuntime(dataStoreContext, sharedObjectRegistry); + assert.throws( + () => rt.createChannel("race-1", "SomeType", {}), + (e: IErrorBase) => + e.errorType === ContainerErrorTypes.usageError && + /detached/.test(e.message), + ); + }); + + it("mints a channel id derived from the race id", () => { + const rt = makeAttachedRuntime(); + const channel = rt.createChannel("my-race", "SomeType", {}); + assert( + channel.id.startsWith("my-race#"), + `channel id ${channel.id} should start with "my-race#"`, + ); + }); + + it("rejects duplicate race id from the same client", () => { + const rt = makeAttachedRuntime(); + rt.createChannel("dup-race", "SomeType", {}); + assert.throws( + () => rt.createChannel("dup-race", "SomeType", {}), + (e: IErrorBase) => + e.errorType === ContainerErrorTypes.usageError && + /already created a racing channel/.test(e.message), + ); + }); + + it("rejects empty race id", () => { + const rt = makeAttachedRuntime(); + assert.throws( + () => rt.createChannel("", "SomeType", {}), + (e: IErrorBase) => e.errorType === ContainerErrorTypes.usageError, + ); + }); + }); }); describe("FluidDataStoreRuntime.isDirty tracking", () => { From 3e8d85c1d36d6902cbb4243ed6a2bdd2926f6411 Mon Sep 17 00:00:00 2001 From: Kian Thompson <102998837+kian-thompson@users.noreply.github.com> Date: Fri, 15 May 2026 23:19:38 +0000 Subject: [PATCH 4/5] feat(datastore): persist race loser->winner redirects in summary Writes a '.races' top-level blob containing the loserToWinner map in FluidDataStoreRuntime.summarize() when the map is non-empty, and reads it back asynchronously during construction. This lets mid-session joiners drop late ops to historical losers deterministically once the load completes. v1 caveat: the read is best-effort and not awaited before op processing. Ops to historical losers may transiently be applied during the load window. Tracked as a follow-up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../runtime/datastore/src/dataStoreRuntime.ts | 62 ++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/packages/runtime/datastore/src/dataStoreRuntime.ts b/packages/runtime/datastore/src/dataStoreRuntime.ts index 1c8876f03202..719044c7096b 100644 --- a/packages/runtime/datastore/src/dataStoreRuntime.ts +++ b/packages/runtime/datastore/src/dataStoreRuntime.ts @@ -43,7 +43,7 @@ import type { ISnapshotTree, ISequencedDocumentMessage, } from "@fluidframework/driver-definitions/internal"; -import { buildSnapshotTree } from "@fluidframework/driver-utils/internal"; +import { buildSnapshotTree, readAndParse } from "@fluidframework/driver-utils/internal"; import type { IIdCompressor } from "@fluidframework/id-compressor"; import { type ISummaryTreeWithStats, @@ -159,6 +159,12 @@ export interface ISharedObjectRegistry { get(name: string): IChannelFactory | undefined; } +/** + * Blob key under which the data store runtime stores its race-id loser->winner + * redirect table in summaries. + */ +const racesBlobKey = ".races"; + /** * The common URL prefix used by legacy DDS type strings. * Factories originally used full URLs (e.g. `"https://graph.microsoft.com/types/map"`); @@ -533,6 +539,18 @@ export class FluidDataStoreRuntime } } + // Race-id v1: rehydrate the loser->winner redirect table from summary. + // Until this async read completes, ops addressed to historical loser + // channel ids may transiently be applied. In practice this matters only + // for sessions where (a) a prior race resolved and (b) late ops to the + // loser are still being received — both rare. A follow-up will gate + // op processing on this load completing. + if (tree?.blobs?.[racesBlobKey] !== undefined) { + void this.hydrateRacesFromSummary(tree).catch((error) => { + this.logger.sendErrorEvent({ eventName: "RaceSummaryLoadFailed" }, error); + }); + } + this.entryPoint = new FluidObjectHandle( new LazyPromise(async () => provideEntryPoint(this)), "", @@ -808,6 +826,44 @@ export class FluidDataStoreRuntime return undefined; } + /** + * Read the `.races` summary blob and rehydrate the `loserToWinner` map. + * See constructor for the v1 caveat that this is best-effort. + */ + private async hydrateRacesFromSummary(tree: ISnapshotTree): Promise { + const blobId = tree.blobs[racesBlobKey]; + if (blobId === undefined) { + return; + } + const payload = await readAndParse<{ loserToWinner: [string, string][] }>( + this.dataStoreContext.storage, + blobId, + ); + if (payload?.loserToWinner !== undefined) { + for (const [loser, winner] of payload.loserToWinner) { + if (!this.loserToWinner.has(loser)) { + this.loserToWinner.set(loser, winner); + } + } + } + } + + /** + * Serialize the loser->winner redirect table for inclusion in summaries. + * Returns undefined if there's nothing to persist (keeps summaries small + * and avoids touching back-compat for data stores that never race). + */ + private serializeRaces(): string | undefined { + if (this.loserToWinner.size === 0) { + return undefined; + } + const entries: [string, string][] = []; + for (const [loser, winner] of this.loserToWinner) { + entries.push([loser, winner]); + } + return JSON.stringify({ loserToWinner: entries }); + } + private createChannelContext(channel: IChannel): void { this.notBoundedChannelContextSet.add(channel.id); const context = new LocalChannelContext( @@ -1312,6 +1368,10 @@ export class FluidDataStoreRuntime summaryBuilder.addWithStats(contextId, contextSummary); }, ); + const racesPayload = this.serializeRaces(); + if (racesPayload !== undefined) { + summaryBuilder.addBlob(racesBlobKey, racesPayload); + } return summaryBuilder.getSummaryTree(); } From 314e2d9639d70d196010d83a1f4558133f64d767 Mon Sep 17 00:00:00 2001 From: Kian Thompson <102998837+kian-thompson@users.noreply.github.com> Date: Fri, 15 May 2026 23:21:59 +0000 Subject: [PATCH 5/5] docs(datastore): changeset + regenerated API reports for race-id - Adds .changeset/race-id-dds-create.md describing the new alpha surface (raceId, OnRaceLost, raceResolved event) and v1 caveats. - Adds @alpha overload declaration on FluidDataStoreRuntime.createChannel so the implementation's release tag matches the @alpha OnRaceLost type it references (fixes ae-incompatible-release-tags). - Escapes '->' in tsdoc comments per tsdoc-escape-greater-than. - Regenerates api-report files for datastore-definitions, runtime-definitions, and datastore. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/race-id-dds-create.md | 22 +++++++++++++++++++ .../datastore-definitions.legacy.alpha.api.md | 13 +++++++++++ .../api-report/datastore.legacy.beta.api.md | 3 +-- .../runtime/datastore/src/dataStoreRuntime.ts | 22 ++++++++++++++++--- .../runtime-definitions.legacy.alpha.api.md | 2 ++ 5 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 .changeset/race-id-dds-create.md diff --git a/.changeset/race-id-dds-create.md b/.changeset/race-id-dds-create.md new file mode 100644 index 000000000000..b98e573ceeb8 --- /dev/null +++ b/.changeset/race-id-dds-create.md @@ -0,0 +1,22 @@ +--- +"@fluidframework/datastore": minor +"@fluidframework/datastore-definitions": minor +"@fluidframework/runtime-definitions": minor +"__section": feature +--- +Race-id DDS create: deterministic FWW resolution for concurrent channel creates + +Adds an opt-in alpha API for resolving racing DDS creates with deterministic first-writer-wins (FWW) semantics. When multiple clients independently create a channel that they consider semantically the same (for example, a singleton DDS attached to a shared key), all clients converge to the same winner without breaking optimistic local application. + +API surface (alpha): +- `IFluidDataStoreRuntime.createChannel(raceId, type, { onLost })` overload — pass a shared `raceId` agreed across racing clients; the runtime mints a unique internal channel id (`${raceId}#${guid}`). +- `IAttachMessage.raceId` — optional field that propagates the race id with the attach op. +- `raceResolved` event on `IFluidDataStoreRuntime` — fires with `{ raceId, winnerChannelId, loserChannelIds }`. +- `OnRaceLost` callback — invoked on losing clients so the app can merge local edits from the loser channel into the winner. + +Resolution semantics: the first attach op for a given `raceId` (per the sequenced order) wins. Subsequent attaches with the same `raceId`, and any channel ops addressed to loser channel ids, are dropped on every client deterministically. Loser->winner redirects are persisted in a `.races` summary blob. + +v1 limitations (tracked as follow-ups): +- Race-id handles, optimistic handle storage, data-store-level races, public `IChannel.dispose()`, and async `onLost` are out of scope. +- The race overload is rejected while the data store is detached or in staging mode. +- The summary redirect table is rehydrated asynchronously on load; ops to historical losers may transiently be applied during the load window. 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 fbf624137081..0c232a3a630f 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 @@ -81,6 +81,10 @@ export interface IFluidDataStoreRuntime extends IEventProvider; @@ -130,6 +134,12 @@ export interface IFluidDataStoreRuntimeEvents extends IEvent { (event: "connected", listener: (clientId: string) => void): any; // (undocumented) (event: "readonly", listener: (isReadOnly: boolean) => void): any; + // @alpha + (event: "raceResolved", listener: (info: { + raceId: string; + winnerChannelId: string; + loserChannelIds: readonly string[]; + }) => void): any; } // @beta @legacy (undocumented) @@ -146,6 +156,9 @@ export type Jsonable = boolean extends (T extends never ? // @beta @legacy export type JsonableTypeWith = undefined | null | boolean | number | string | T | Internal_InterfaceOfJsonableTypesWith | ArrayLike>; +// @alpha +export type OnRaceLost = (loser: IChannel, winnerChannelId: string) => void; + // @beta @legacy export type Serializable = Jsonable; 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 1ee912869d4b..14047c1a9ca7 100644 --- a/packages/runtime/datastore/api-report/datastore.legacy.beta.api.md +++ b/packages/runtime/datastore/api-report/datastore.legacy.beta.api.md @@ -36,8 +36,7 @@ export class FluidDataStoreRuntime extends TypedEventEmitter(); /** - * Loser channel id -> winner channel id. Channel ops addressed to any + * Loser channel id -\> winner channel id. Channel ops addressed to any * loser channel id are dropped deterministically by every client. * * Persisted in the `.races` summary blob (see TODO in `summarize`) so that @@ -719,6 +719,22 @@ export class FluidDataStoreRuntime this.identifyLocalChangeInSummarizer("DDSCreatedInSummarizer", id, type); } + /** + * {@inheritDoc @fluidframework/datastore-definitions#IFluidDataStoreRuntime.createChannel} + */ + public createChannel(id: string | undefined, type: string): IChannel; + /** + * Race-id overload — see the + * {@link @fluidframework/datastore-definitions#IFluidDataStoreRuntime.createChannel} + * interface declaration for full semantics. + * + * @alpha + */ + public createChannel( + raceId: string, + type: string, + raceOptions: { onLost?: OnRaceLost }, + ): IChannel; public createChannel( idArg: string | undefined, type: string, @@ -814,7 +830,7 @@ export class FluidDataStoreRuntime } /** - * Reverse lookup: channel id -> race id, used when constructing outbound + * Reverse lookup: channel id -\> race id, used when constructing outbound * attach messages to know if a raceId field should be included. */ private getRaceIdForChannel(channelId: string): string | undefined { @@ -849,7 +865,7 @@ export class FluidDataStoreRuntime } /** - * Serialize the loser->winner redirect table for inclusion in summaries. + * Serialize the loser-\>winner redirect table for inclusion in summaries. * Returns undefined if there's nothing to persist (keeps summaries small * and avoids touching back-compat for data stores that never race). */ 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 9f18e360b28c..a75ccfae5026 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 @@ -68,6 +68,8 @@ export enum FlushMode { // @beta @legacy export interface IAttachMessage { id: string; + // @alpha + raceId?: string; snapshot: ITree; type: string; }