diff --git a/.changeset/claims-dds-shareddirectory.md b/.changeset/claims-dds-shareddirectory.md new file mode 100644 index 000000000000..a36df852d273 --- /dev/null +++ b/.changeset/claims-dds-shareddirectory.md @@ -0,0 +1,16 @@ +--- +"@fluidframework/map": minor +"__section": feature +--- +Add first-writer-wins claim API on SharedDirectory (`trySetClaim`/`isClaimed`) + +`ISharedDirectory` now exposes two new methods on the root directory: + +- `trySetClaim(key, value): Promise` — Attempts to claim a root-level key with a value using first-writer-wins semantics across all clients. Resolves to `"Success"` if this client (or this client's already-pending attempt) won, or `"AlreadyClaimed"` if another client's claim was sequenced first. +- `isClaimed(key): boolean` — Returns `true` if the root-level key is currently claimed. + +Once a key is claimed, the value is immutable: `set` and `delete` for that key throw `UsageError`, and `get` returns the claimed value. `clear()` does not remove claims. Claims survive summary/load. + +This API is gated behind a runtime opt-in. To enable it, set `enableDdsClaims: true` on the data store runtime options. Calling `trySetClaim` or `isClaimed` without the flag enabled throws `UsageError`. + +Claims are only available on the root `ISharedDirectory`; subdirectories (`IDirectory`) intentionally do not expose these methods. diff --git a/packages/dds/map/api-report/map.legacy.beta.api.md b/packages/dds/map/api-report/map.legacy.beta.api.md index 1ed784c556a9..074641b9c897 100644 --- a/packages/dds/map/api-report/map.legacy.beta.api.md +++ b/packages/dds/map/api-report/map.legacy.beta.api.md @@ -4,6 +4,9 @@ ```ts +// @beta @legacy +export type ClaimResult = "Success" | "AlreadyClaimed"; + // @beta @sealed @legacy export class DirectoryFactory implements IChannelFactory { static readonly Attributes: IChannelAttributes; @@ -14,6 +17,13 @@ export class DirectoryFactory implements IChannelFactory { get type(): string; } +// @beta @legacy +export interface IClaimable { + // (undocumented) + isClaimed(key: string): boolean; + trySetClaim(key: string, value: V): Promise; +} + // @beta @deprecated @legacy export interface ICreateInfo { ccIds: string[]; @@ -53,6 +63,7 @@ export interface IDirectoryEvents extends IEvent { // @beta @deprecated @legacy export interface IDirectoryNewStorageFormat { blobs: string[]; + claims?: [string, ISerializableValue][]; content: IDirectoryDataObject; } @@ -73,6 +84,8 @@ export interface ISharedDirectory extends ISharedObject; // (undocumented) readonly [Symbol.toStringTag]: string; + isClaimed(key: string): boolean; + trySetClaim(key: string, value: unknown): Promise; } // @beta @sealed @legacy diff --git a/packages/dds/map/package.json b/packages/dds/map/package.json index d44204f11a05..1482796a4782 100644 --- a/packages/dds/map/package.json +++ b/packages/dds/map/package.json @@ -172,7 +172,11 @@ "typescript": "~5.4.5" }, "typeValidation": { - "broken": {}, + "broken": { + "TypeAlias_SharedDirectory": { + "forwardCompat": false + } + }, "entrypoint": "legacy" } } diff --git a/packages/dds/map/src/claims.ts b/packages/dds/map/src/claims.ts new file mode 100644 index 000000000000..e4d3182a5fca --- /dev/null +++ b/packages/dds/map/src/claims.ts @@ -0,0 +1,47 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +/** + * Result of a {@link IClaimable.trySetClaim} call. + * + * @remarks + * - `"Success"` indicates that this client's claim is the sequenced winner for the key. + * - `"AlreadyClaimed"` indicates that some other claim for the key was sequenced first; + * the key is now immutable for the lifetime of the document. + * + * @legacy @beta + */ +export type ClaimResult = "Success" | "AlreadyClaimed"; + +/** + * First-writer-wins ("claim") API for keyed singleton entries on a DDS. + * + * @remarks + * Once a key has been successfully claimed by any client, subsequent attempts to claim the + * same key from any client will resolve to {@link ClaimResult | `"AlreadyClaimed"`}. Writes + * to the same key via the DDS's normal mutation APIs (e.g. `set`/`delete`/`clear`) are + * dropped or rejected, so the claimed value is immutable for the lifetime of the document. + * + * This API is opt-in. Implementations gate access behind a runtime option (e.g. + * `runtime.options.enableDdsClaims`); when disabled, {@link IClaimable.trySetClaim} throws + * a `UsageError`. + * + * @legacy @beta + */ +export interface IClaimable { + /** + * Attempt to publish `value` for `key` as a singleton. + * + * @returns A promise that resolves once the claim has been ack'd by the service: + * `"Success"` if this client won the race; `"AlreadyClaimed"` if some other claim was + * sequenced first. + */ + trySetClaim(key: string, value: V): Promise; + + /** + * @returns `true` if `key` has been sequenced as claimed (by any client). + */ + isClaimed(key: string): boolean; +} diff --git a/packages/dds/map/src/directory.ts b/packages/dds/map/src/directory.ts index 01f7e7fa0f07..9f8b51182b3c 100644 --- a/packages/dds/map/src/directory.ts +++ b/packages/dds/map/src/directory.ts @@ -7,7 +7,7 @@ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/prefer-optional-chain */ import { TypedEventEmitter } from "@fluid-internal/client-utils"; -import { assert, unreachableCase } from "@fluidframework/core-utils/internal"; +import { assert, Deferred, unreachableCase } from "@fluidframework/core-utils/internal"; import type { IChannelAttributes, IFluidDataStoreRuntime, @@ -38,6 +38,7 @@ import { } from "@fluidframework/telemetry-utils/internal"; import path from "path-browserify"; +import type { ClaimResult } from "./claims.js"; import type { IDirectory, IDirectoryEvents, @@ -207,10 +208,39 @@ export type IDirectorySubDirectoryOperation = | IDirectoryCreateSubDirectoryOperation | IDirectoryDeleteSubDirectoryOperation; +/** + * Operation indicating a first-writer-wins claim should be set for a key on the + * SharedDirectory root. + * + * @remarks Claims live at the SharedDirectory level (not per-subdirectory) so the op + * carries no `path`. + */ +export interface IDirectoryClaimOperation { + /** + * String identifier of the operation type. + */ + type: "claim"; + + /** + * Key being claimed. + */ + key: string; + + /** + * Value to bind to the key. Always a Plain `ISerializableValue` so handles get + * encoded using the shared serializer. + */ + // eslint-disable-next-line import-x/no-deprecated + value: ISerializableValue; +} + /** * Any operation on a directory. */ -export type IDirectoryOperation = IDirectoryStorageOperation | IDirectorySubDirectoryOperation; +export type IDirectoryOperation = + | IDirectoryStorageOperation + | IDirectorySubDirectoryOperation + | IDirectoryClaimOperation; interface PendingKeySet { type: "set"; @@ -339,6 +369,16 @@ export interface IDirectoryNewStorageFormat { * Storage content representing directory data that was not serialized. */ content: IDirectoryDataObject; + + /** + * Sequenced root-level claims, as `[key, encodedValue]` tuples. + * + * @remarks + * Optional for back-compat: snapshots written before claim support is enabled + * omit this field, and the loader treats it as empty. + */ + // eslint-disable-next-line import-x/no-deprecated + claims?: [string, ISerializableValue][]; } /** @@ -438,6 +478,32 @@ export class SharedDirectory */ private readonly messageHandlers = new Map(); + /** + * Sequenced claim values, keyed by claim key. A key present in this map has been + * sequenced as claimed by some client; the value is the in-memory (decoded) claim + * value. Read-back via {@link SharedDirectory.get} on the root. + */ + private readonly sequencedClaimedValues = new Map(); + + /** + * Keys this client locally won via a sequenced claim op. Used to make repeated + * `trySetClaim` calls from the original winner resolve to `"Success"` (rather than + * `"AlreadyClaimed"`). Not persisted in summary; on reload no client is the winner + * of pre-existing claims (so all rehydrated claims look like loser-on-retry). + */ + private readonly wonClaims = new Set(); + + /** + * Pending in-flight claim deferreds, keyed by claim key. Resolved when the + * corresponding claim op (or a competing winner) is sequenced. The `value` is + * cached so that on local processing we can use the original in-memory reference + * rather than a value that has round-tripped through the runtime serializer. + */ + private readonly pendingClaims = new Map< + string, + { deferred: Deferred; value: unknown } + >(); + /** * Constructs a new shared directory. If the object is non-local an id and service interfaces will * be provided. @@ -470,6 +536,9 @@ export class SharedDirectory // TODO: Use `unknown` instead (breaking change). // eslint-disable-next-line @typescript-eslint/no-explicit-any public get(key: string): T | undefined { + if (this.sequencedClaimedValues.has(key)) { + return this.sequencedClaimedValues.get(key) as T | undefined; + } return this.root.get(key); } @@ -477,11 +546,19 @@ export class SharedDirectory * {@inheritDoc IDirectory.set} */ public set(key: string, value: T): this { + if (this.sequencedClaimedValues.has(key)) { + throw new UsageError(`Cannot set key "${key}": it has been claimed and is immutable.`); + } this.root.set(key, value); return this; } public dispose(error?: Error): void { + // Resolve any pending claim deferreds so callers don't hang. + for (const [key, pending] of this.pendingClaims) { + pending.deferred.resolve("AlreadyClaimed"); + this.pendingClaims.delete(key); + } this.root.dispose(error); } @@ -495,16 +572,92 @@ export class SharedDirectory * @returns True if the key existed and was deleted, false if it did not exist */ public delete(key: string): boolean { + if (this.sequencedClaimedValues.has(key)) { + throw new UsageError( + `Cannot delete key "${key}": it has been claimed and is immutable.`, + ); + } return this.root.delete(key); } /** - * Deletes all keys from within this IDirectory. + * Deletes all non-claimed keys from within this IDirectory. + * + * @remarks Claimed keys at the root survive `clear()`. */ public clear(): void { + // Claims are sequenced state and survive a clear; the underlying root.clear() + // only operates on the root subdirectory's storage and is unaffected. this.root.clear(); } + /** + * Returns whether the given key has been sequenced as claimed on the root. + * + * @returns `true` if `key` has been sequenced as claimed on the root. + */ + public isClaimed(key: string): boolean { + return this.sequencedClaimedValues.has(key); + } + + /** + * Attempt to publish `value` under `key` as a singleton on the root, with + * first-writer-wins semantics. See {@link ISharedDirectory.trySetClaim}. + */ + public async trySetClaim(key: string, value: unknown): Promise { + if (this.runtime.options?.enableDdsClaims !== true) { + throw new UsageError( + "SharedDirectory.trySetClaim requires runtime option enableDdsClaims=true.", + ); + } + if (key === undefined || key === null) { + throw new UsageError("Undefined and null claim keys are not supported"); + } + + // Already-sequenced claim → resolve based on whether *this* client won it. + if (this.sequencedClaimedValues.has(key)) { + return this.wonClaims.has(key) ? "Success" : "AlreadyClaimed"; + } + + // Detached: synchronously install the claim into sequenced state and persist + // it via the attach summary. + if (!this.isAttached()) { + this.applyClaimLocally(key, value, /* won */ true); + return "Success"; + } + + // If we already have a pending claim for this key, return its deferred so the + // caller awaits the same outcome. + const existing = this.pendingClaims.get(key); + if (existing !== undefined) { + return existing.deferred.promise; + } + + const deferred = new Deferred(); + this.pendingClaims.set(key, { deferred, value }); + + const op: IDirectoryClaimOperation = { + type: "claim", + key, + // Match the wire format used by IDirectorySetOperation: pass the value raw + // inside a Plain ISerializableValue. Handles are encoded by the runtime's + // shared serializer when the op contents are stringified. + value: { type: ValueType[ValueType.Plain], value }, + }; + this.submitLocalMessage(op); + return deferred.promise; + } + + /** + * Apply a sequenced claim to local in-memory state. + */ + private applyClaimLocally(key: string, value: unknown, won: boolean): void { + this.sequencedClaimedValues.set(key, value); + if (won) { + this.wonClaims.add(key); + } + } + /** * Checks whether the given key exists in this IDirectory. * @param key - The key to check @@ -709,6 +862,20 @@ export class SharedDirectory for (const blobContent of blobContents) { this.populate(blobContent as IDirectoryDataObject); } + // Claims are optional for back-compat: snapshots written before claim support + // was enabled simply omit this field. + if (Array.isArray(newFormat.claims)) { + for (const [key, serializable] of newFormat.claims) { + const parsedSerializable = parseHandles( + serializable, + this.serializer, + // eslint-disable-next-line import-x/no-deprecated + ) as ISerializableValue; + migrateIfSharedSerializable(parsedSerializable, this.serializer, this.handle); + // On load no client is the winner of pre-existing claims. + this.applyClaimLocally(key, parsedSerializable.value, /* won */ false); + } + } } else { // Old storage format this.populate(data as IDirectoryDataObject); @@ -827,6 +994,15 @@ export class SharedDirectory localOpMetadata: DirectoryLocalOpMetadata, ): void { const op: IDirectoryOperation = content as IDirectoryOperation; + if (op.type === "claim") { + const pending = this.pendingClaims.get(op.key); + if (pending !== undefined) { + this.pendingClaims.delete(op.key); + // Local op was rolled back before being sequenced; treat as a lost claim. + pending.deferred.resolve("AlreadyClaimed"); + } + return; + } const subdir = this.getWorkingDirectory(op.path) as SubDirectory | undefined; if (subdir) { subdir.rollback(op, localOpMetadata); @@ -879,6 +1055,10 @@ export class SharedDirectory localOpMetadata: EditLocalOpMetadata | undefined, clientSequenceNumber: number, ) => { + // Drop deletes for claimed root-level keys. + if (op.path === posix.sep && this.sequencedClaimedValues.has(op.key)) { + return; + } const subdir = this.getSequencedWorkingDirectory(op.path) as SubDirectory | undefined; if (subdir !== undefined && !subdir?.disposed) { subdir.processDeleteMessage(msgEnvelope, op, local, localOpMetadata); @@ -899,6 +1079,10 @@ export class SharedDirectory localOpMetadata: EditLocalOpMetadata | undefined, clientSequenceNumber: number, ) => { + // Drop sets for claimed root-level keys. + if (op.path === posix.sep && this.sequencedClaimedValues.has(op.key)) { + return; + } const subdir = this.getSequencedWorkingDirectory(op.path) as SubDirectory | undefined; if (subdir !== undefined && !subdir?.disposed) { migrateIfSharedSerializable(op.value, this.serializer, this.handle); @@ -914,6 +1098,36 @@ export class SharedDirectory }, }); + this.messageHandlers.set("claim", { + process: ( + _msgEnvelope: ISequencedMessageEnvelope, + op: IDirectoryClaimOperation, + local: boolean, + _localOpMetadata: DirectoryLocalOpMetadata | undefined, + _clientSequenceNumber: number, + ) => { + const alreadyClaimed = this.sequencedClaimedValues.has(op.key); + const pending = this.pendingClaims.get(op.key); + if (!alreadyClaimed) { + migrateIfSharedSerializable(op.value, this.serializer, this.handle); + // For local ops, prefer the cached original value (matches the + // pattern used for set ops, which use pending data for `local`). + const decoded: unknown = + local && pending !== undefined ? pending.value : op.value.value; + this.applyClaimLocally(op.key, decoded, /* won */ local); + } + if (pending !== undefined) { + this.pendingClaims.delete(op.key); + pending.deferred.resolve(local && !alreadyClaimed ? "Success" : "AlreadyClaimed"); + } + }, + resubmit: (op: IDirectoryClaimOperation) => { + if (this.pendingClaims.has(op.key)) { + this.submitLocalMessage(op); + } + }, + }); + this.messageHandlers.set("createSubDirectory", { process: ( msgEnvelope: ISequencedMessageEnvelope, @@ -984,6 +1198,14 @@ export class SharedDirectory */ protected applyStashedOp(op: unknown): void { const directoryOp = op as IDirectoryOperation; + if (directoryOp.type === "claim") { + migrateIfSharedSerializable(directoryOp.value, this.serializer, this.handle); + // Best-effort stashed-op replay: re-issue the claim attempt. + this.trySetClaim(directoryOp.key, directoryOp.value.value).catch(() => { + /* swallow — stashed op replay is best-effort */ + }); + return; + } const dir = this.getWorkingDirectory(directoryOp.path); switch (directoryOp.type) { case "clear": { @@ -1075,6 +1297,21 @@ export class SharedDirectory blobs, content, }; + if (this.sequencedClaimedValues.size > 0) { + // eslint-disable-next-line import-x/no-deprecated + const claims: [string, ISerializableValue][] = []; + for (const [key, value] of this.sequencedClaimedValues) { + const encoded = serializeValue(value, serializer, this.handle); + claims.push([ + key, + { + type: encoded.type, + value: encoded.value && (JSON.parse(encoded.value) as object), + }, + ]); + } + newFormat.claims = claims; + } builder.addBlob(snapshotFileName, JSON.stringify(newFormat)); return builder.getSummaryTree(); diff --git a/packages/dds/map/src/index.ts b/packages/dds/map/src/index.ts index e7a39b31df7b..5c94c925f18b 100644 --- a/packages/dds/map/src/index.ts +++ b/packages/dds/map/src/index.ts @@ -25,6 +25,7 @@ export type { ISharedMapEvents, IValueChanged, } from "./interfaces.js"; +export type { ClaimResult, IClaimable } from "./claims.js"; export { SharedMap } from "./mapFactory.js"; export { SharedDirectory } from "./directoryFactory.js"; diff --git a/packages/dds/map/src/interfaces.ts b/packages/dds/map/src/interfaces.ts index ba4936e316aa..68947ac60591 100644 --- a/packages/dds/map/src/interfaces.ts +++ b/packages/dds/map/src/interfaces.ts @@ -14,6 +14,8 @@ import type { ISharedObjectEvents, } from "@fluidframework/shared-object-base/internal"; +import type { ClaimResult } from "./claims.js"; + /** * Type of "valueChanged" event parameter. * @sealed @@ -311,6 +313,27 @@ export interface ISharedDirectory // eslint-disable-next-line @typescript-eslint/no-explicit-any [Symbol.iterator](): IterableIterator<[string, any]>; readonly [Symbol.toStringTag]: string; + + /** + * Attempt to publish `value` under `key` as a singleton on the root of this + * {@link ISharedDirectory}, with first-writer-wins semantics. + * + * @remarks + * - Only callable on the root directory; calling on a subdirectory throws a `UsageError`. + * - Requires the runtime option `enableDdsClaims === true`; otherwise throws a `UsageError`. + * - Once a key has been claimed, subsequent `set`/`delete`/`clear` for that key are no-ops + * and the claim cannot be removed. + * - `value` may be any JSON-serializable value, including `IFluidHandle`s. + */ + trySetClaim(key: string, value: unknown): Promise; + + /** + * Returns whether the given key has been sequenced as claimed on the root. + * + * @returns `true` if `key` has been sequenced as claimed on the root of this + * {@link ISharedDirectory}. + */ + isClaimed(key: string): boolean; } /** diff --git a/packages/dds/map/src/test/mocha/directory.claims.spec.ts b/packages/dds/map/src/test/mocha/directory.claims.spec.ts new file mode 100644 index 000000000000..e66087cd958b --- /dev/null +++ b/packages/dds/map/src/test/mocha/directory.claims.spec.ts @@ -0,0 +1,243 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { strict as assert } from "node:assert"; + +import { AttachState } from "@fluidframework/container-definitions"; +import { + MockContainerRuntimeFactory, + MockFluidDataStoreRuntime, + MockSharedObjectServices, + MockStorage, +} from "@fluidframework/test-runtime-utils/internal"; + +import type { IDirectoryNewStorageFormat } from "../../directory.js"; +import { type ISharedDirectory, SharedDirectory } from "../../index.js"; + +function makeRuntime(enableClaims: boolean): MockFluidDataStoreRuntime { + const runtime = new MockFluidDataStoreRuntime({ + registry: [SharedDirectory.getFactory()], + }); + if (enableClaims) { + runtime.options.enableDdsClaims = true; + } + return runtime; +} + +function createConnectedDirectoryWithClaims( + id: string, + runtimeFactory: MockContainerRuntimeFactory, + enableClaims = true, +): { directory: ISharedDirectory; runtime: MockFluidDataStoreRuntime } { + const runtime = makeRuntime(enableClaims); + const containerRuntime = runtimeFactory.createContainerRuntime(runtime); + const services = { + deltaConnection: runtime.createDeltaConnection(), + objectStorage: new MockStorage(), + }; + const directory = SharedDirectory.create(runtime, id); + directory.connect(services); + void containerRuntime; + return { directory, runtime }; +} + +describe("SharedDirectory claims", () => { + describe("Feature flag", () => { + it("trySetClaim throws when enableDdsClaims is not set", async () => { + const factory = new MockContainerRuntimeFactory(); + const { directory } = createConnectedDirectoryWithClaims("d", factory, false); + await assert.rejects(directory.trySetClaim("k", "v"), /enableDdsClaims/); + }); + + it("isClaimed returns false for unknown keys (regardless of flag)", () => { + const factory = new MockContainerRuntimeFactory(); + const { directory } = createConnectedDirectoryWithClaims("d", factory, false); + assert.strictEqual(directory.isClaimed("k"), false); + }); + }); + + describe("Single client", () => { + it("first claim succeeds and is observable via get", async () => { + const factory = new MockContainerRuntimeFactory(); + const { directory } = createConnectedDirectoryWithClaims("d", factory); + const promise = directory.trySetClaim("k", "v1"); + factory.processAllMessages(); + const result = await promise; + assert.strictEqual(result, "Success"); + assert.strictEqual(directory.isClaimed("k"), true); + assert.strictEqual(directory.get("k"), "v1"); + }); + + it("repeated claim from winner returns Success", async () => { + const factory = new MockContainerRuntimeFactory(); + const { directory } = createConnectedDirectoryWithClaims("d", factory); + const p1 = directory.trySetClaim("k", "v1"); + factory.processAllMessages(); + assert.strictEqual(await p1, "Success"); + // Second call: synchronous resolution since key is locally known claimed and + // this client is the recorded winner. + const r2 = await directory.trySetClaim("k", "v2"); + assert.strictEqual(r2, "Success"); + // Value is unchanged — claim is immutable. + assert.strictEqual(directory.get("k"), "v1"); + }); + + it("set on a claimed key throws synchronously", async () => { + const factory = new MockContainerRuntimeFactory(); + const { directory } = createConnectedDirectoryWithClaims("d", factory); + const p1 = directory.trySetClaim("k", "v1"); + factory.processAllMessages(); + await p1; + assert.throws(() => directory.set("k", "other"), /claimed/); + }); + + it("delete on a claimed key throws synchronously", async () => { + const factory = new MockContainerRuntimeFactory(); + const { directory } = createConnectedDirectoryWithClaims("d", factory); + const p1 = directory.trySetClaim("k", "v1"); + factory.processAllMessages(); + await p1; + assert.throws(() => directory.delete("k"), /claimed/); + }); + + it("clear preserves claimed entries", async () => { + const factory = new MockContainerRuntimeFactory(); + const { directory } = createConnectedDirectoryWithClaims("d", factory); + directory.set("regular", "x"); + const p = directory.trySetClaim("k", "v1"); + factory.processAllMessages(); + await p; + directory.clear(); + factory.processAllMessages(); + assert.strictEqual(directory.get("k"), "v1"); + assert.strictEqual(directory.isClaimed("k"), true); + }); + }); + + describe("Two-client race", () => { + it("exactly one client gets Success; the other gets AlreadyClaimed", async () => { + const factory = new MockContainerRuntimeFactory(); + const { directory: d1 } = createConnectedDirectoryWithClaims("d1", factory); + const { directory: d2 } = createConnectedDirectoryWithClaims("d2", factory); + + const p1 = d1.trySetClaim("k", "v-from-1"); + const p2 = d2.trySetClaim("k", "v-from-2"); + factory.processAllMessages(); + const [r1, r2] = await Promise.all([p1, p2]); + + const successes = [r1, r2].filter((r) => r === "Success").length; + const failures = [r1, r2].filter((r) => r === "AlreadyClaimed").length; + assert.strictEqual(successes, 1, "exactly one Success expected"); + assert.strictEqual(failures, 1, "exactly one AlreadyClaimed expected"); + + // Both observers see the same claimed value. + assert.strictEqual(d1.get("k"), d2.get("k")); + assert.ok(d1.isClaimed("k")); + assert.ok(d2.isClaimed("k")); + }); + + it("loser repeating trySetClaim still gets AlreadyClaimed", async () => { + const factory = new MockContainerRuntimeFactory(); + const { directory: d1 } = createConnectedDirectoryWithClaims("d1", factory); + const { directory: d2 } = createConnectedDirectoryWithClaims("d2", factory); + + const p1 = d1.trySetClaim("k", "v-from-1"); + const p2 = d2.trySetClaim("k", "v-from-2"); + factory.processAllMessages(); + const [r1, r2] = await Promise.all([p1, p2]); + const winner = r1 === "Success" ? d1 : d2; + const loser = r1 === "Success" ? d2 : d1; + void r2; + + // Repeats: winner sees Success, loser sees AlreadyClaimed. + assert.strictEqual(await winner.trySetClaim("k", "again"), "Success"); + assert.strictEqual(await loser.trySetClaim("k", "again"), "AlreadyClaimed"); + }); + }); + + describe("Detached", () => { + it("trySetClaim resolves synchronously to Success and survives attach", async () => { + const runtime = new MockFluidDataStoreRuntime({ + attachState: AttachState.Detached, + registry: [SharedDirectory.getFactory()], + }); + runtime.options.enableDdsClaims = true; + const directory = SharedDirectory.create(runtime, "d"); + const result = await directory.trySetClaim("k", "v1"); + assert.strictEqual(result, "Success"); + assert.strictEqual(directory.get("k"), "v1"); + + // Round-trip via summary → reload. + const summary = directory.getAttachSummary().summary; + const services = MockSharedObjectServices.createFromSummary(summary); + const runtime2 = makeRuntime(true); + const factory = SharedDirectory.getFactory(); + const reloaded = (await factory.load( + runtime2, + "d", + services, + factory.attributes, + )) as ISharedDirectory; + assert.strictEqual(reloaded.isClaimed("k"), true); + assert.strictEqual(reloaded.get("k"), "v1"); + }); + }); + + describe("Summary back-compat", () => { + it("loads a snapshot without a claims field", async () => { + const oldFormat: IDirectoryNewStorageFormat = { + blobs: [], + content: { storage: { foo: { type: "Plain", value: "bar" } } }, + }; + const services = MockSharedObjectServices.createFromSummary({ + type: 1, // SummaryType.Tree + tree: { + header: { + type: 2, // SummaryType.Blob + content: JSON.stringify(oldFormat), + }, + }, + } as never); + const runtime = makeRuntime(true); + const factory = SharedDirectory.getFactory(); + const directory = (await factory.load( + runtime, + "d", + services, + factory.attributes, + )) as ISharedDirectory; + assert.strictEqual(directory.get("foo"), "bar"); + assert.strictEqual(directory.isClaimed("foo"), false); + }); + }); + + describe("Sequenced rehydration", () => { + it("reload from summary keeps claimed values; new client is loser-on-retry", async () => { + const factory = new MockContainerRuntimeFactory(); + const { directory: d1 } = createConnectedDirectoryWithClaims("d1", factory); + const p1 = d1.trySetClaim("k", "v1"); + factory.processAllMessages(); + await p1; + + const summary = d1.getAttachSummary().summary; + const services = MockSharedObjectServices.createFromSummary(summary); + const runtime2 = makeRuntime(true); + const dirFactory = SharedDirectory.getFactory(); + const reloaded = (await dirFactory.load( + runtime2, + "d2", + services, + dirFactory.attributes, + )) as ISharedDirectory; + assert.strictEqual(reloaded.isClaimed("k"), true); + assert.strictEqual(reloaded.get("k"), "v1"); + + // On the reloaded client, no client is the winner; trySetClaim returns + // AlreadyClaimed (loser-on-retry behavior per spec). + const r = await reloaded.trySetClaim("k", "v2"); + assert.strictEqual(r, "AlreadyClaimed"); + }); + }); +}); diff --git a/packages/dds/map/src/test/types/validateMapPrevious.generated.ts b/packages/dds/map/src/test/types/validateMapPrevious.generated.ts index 504f1fdb98e8..dfd2663e6cd0 100644 --- a/packages/dds/map/src/test/types/validateMapPrevious.generated.ts +++ b/packages/dds/map/src/test/types/validateMapPrevious.generated.ts @@ -204,6 +204,7 @@ declare type current_as_old_for_Interface_IValueChanged = requireAssignableTo, TypeOnly> /*