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
10 changes: 10 additions & 0 deletions .changeset/version-mark-resolver-access-point.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@fluidframework/container-runtime-definitions": minor
"@fluidframework/container-runtime": minor
"__section": legacy
---
Expose the version mark resolver on IContainerRuntime

`IContainerRuntime` now exposes `versionMarkResolver: IVersionMarkResolver`, the supported access point for the version mark resolver APIs (`IVersionMarkResolver`, `ResolveResult`, `VersionMarkCapture`), which move to `@fluidframework/container-runtime-definitions`. Hosts obtain the resolver from the runtime instead of the concrete `ContainerRuntime` class.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: can we link to the API docs for the existing APIs here?


`ResolveResult`'s `pending` and `unresolvable` results also gain an optional `reason?: string`, an opaque diagnostic string for logging only. Hosts drive behavior from `kind`; `reason` is not a contract and must not be branched on.
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface IContainerRuntime extends IProvideFluidDataStoreRegistry, ICont
readonly scope: FluidObject;
// (undocumented)
readonly storage: IContainerStorageService;
readonly versionMarkResolver: IVersionMarkResolver;
}

// @beta @sealed @legacy (undocumented)
Expand Down Expand Up @@ -94,6 +95,26 @@ export interface ISummarizerObservabilityProps {
numUnsummarizedRuntimeOps: number;
}

// @beta @legacy
export interface IVersionMarkResolver {
onBatchSequenced(listener: (batchId: string, sequenceNumber: number, timestamp?: number) => void): () => void;
resolve(batchId: string, sequenceNumberLowerBound: number): Promise<ResolveResult>;
sealAndCaptureVersionMark(): VersionMarkCapture;
}

// @beta @legacy
export type ResolveResult = {
readonly kind: "resolved";
readonly sequenceNumber: number;
readonly timestamp?: number;
} | {
readonly kind: "pending";
readonly reason?: string;
} | {
readonly kind: "unresolvable";
readonly reason?: string;
};

// @beta @sealed @legacy (undocumented)
export type SummarizerStopReason =
/**
Expand Down Expand Up @@ -129,6 +150,17 @@ export type SummarizerStopReason =
*/
| "latestSummaryStateStale";

// @beta @legacy
export type VersionMarkCapture = {
readonly kind: "pending";
readonly batchId: string;
readonly sequenceNumberLowerBound: number;
} | {
readonly kind: "resolved";
readonly sequenceNumber: number;
readonly timestamp?: number;
};

// (No @packageDocumentation comment for this package)

```
6 changes: 5 additions & 1 deletion packages/runtime/container-runtime-definitions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,11 @@
"typescript": "~6.0.3"
},
"typeValidation": {
"broken": {},
"broken": {
"Interface_IContainerRuntimeWithResolveHandle_Deprecated": {
"forwardCompat": false
}
},
"entrypoint": "legacy"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type {
} from "@fluidframework/runtime-definitions/internal";

import type { ContainerExtensionStore } from "./containerExtension.js";
import type { IVersionMarkResolver } from "./versionMarks.js";

/**
* @deprecated Will be removed in future major release. Migrate all usage of IFluidRouter to the "entryPoint" pattern. Refer to Removing-IFluidRouter.md
Expand Down Expand Up @@ -185,6 +186,11 @@ export interface IContainerRuntime
readonly storage: IContainerStorageService;
readonly flushMode: FlushMode;
readonly scope: FluidObject;
/**
* Host-facing resolver for app-stored version mark locators.
*/
readonly versionMarkResolver: IVersionMarkResolver;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

should this be optional? is this a breaking change?


/**
* Indicates the attachment state of the container to a host service.
*/
Expand Down
5 changes: 5 additions & 0 deletions packages/runtime/container-runtime-definitions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,8 @@ export type {
ISummarizerObservabilityProps,
ISummarizerEvents,
} from "./containerRuntime.js";
export type {
IVersionMarkResolver,
ResolveResult,
VersionMarkCapture,
} from "./versionMarks.js";
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ declare type current_as_old_for_Interface_IContainerRuntimeEvents = requireAssig
* typeValidation.broken:
* "Interface_IContainerRuntimeWithResolveHandle_Deprecated": {"forwardCompat": false}
*/
// @ts-expect-error compatibility expected to be broken
declare type old_as_current_for_Interface_IContainerRuntimeWithResolveHandle_Deprecated = requireAssignableTo<TypeOnly<old.IContainerRuntimeWithResolveHandle_Deprecated>, TypeOnly<current.IContainerRuntimeWithResolveHandle_Deprecated>>

/*
Expand Down
121 changes: 121 additions & 0 deletions packages/runtime/container-runtime-definitions/src/versionMarks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*!
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
* Licensed under the MIT License.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should these types be @sealed?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Deep Review: Good question. IVersionMarkResolver should be @sealed. It's runtime-owned ("Runtime-owned resolver for app-stored version mark locators") and implemented only by Fluid. The same forward-compat argument this PR relies on for adding a required member to the @sealed IContainerRuntime applies here: without @sealed, a future member addition to IVersionMarkResolver would be a forward-compat break for any host implementer/deriver, whereas @sealed documents that only Fluid implements it. Adding it and regenerating the api-reports is the safe, consistent call.

For ResolveResult and VersionMarkCapture, @sealed doesn't apply the same way — they're type-alias unions, not interfaces, so there's no implementer to seal against.

One thing to confirm: if hosts are only ever expected to consume IVersionMarkResolver (never implement it), seal it; if some host implementation is intended, that changes the call — worth stating either way.

/**
* Result of resolving a pending batchId. A resolved result includes the matched batch's last op server
* timestamp when available. The property is optional for compatibility with previously stored results.
*
* @remarks
* `kind` is the stable lifecycle disposition hosts drive their behavior from:
*
* - `resolved`: the mark resolved and can be used.
* - `pending`: the mark has not resolved yet but should be retained because it may become resolvable later.
* - `unresolvable`: resolution is terminal; stop retrying and leave the mark unresolved.
*
* `reason` is an optional, opaque diagnostic string explaining *why* the resolver returned that `kind`. It
* exists for logging and diagnostics only; hosts must not branch on it. New `reason` values may be added,
* changed, or omitted at any time, so acting on `kind` alone is always correct. It is transient operational
* context (not persisted). A future state that needs genuinely different host behavior should be a new
* `kind`, not a new `reason`.
*
* @legacy @beta
*/
export type ResolveResult =
| {
readonly kind: "resolved";
readonly sequenceNumber: number;
readonly timestamp?: number;
}
| {
/**
* The mark has not resolved yet but should be retained because it may become resolvable later.
*
* @remarks Diagnostic strings the runtime may set on `reason` (log-only, do not branch on them):
*
* - `awaitingSequence`: the runtime has not sequenced far enough to resolve the mark yet.
* - `historicalOpsUnavailable`: the current loader does not provide the historical-op capability
* needed to resolve an older mark; a later load with a capable loader may resolve it.
*/
readonly kind: "pending";
readonly reason?: string;
}
| {
/**
* Resolution is terminal. Stop retrying and leave the mark unresolved.
*
* @remarks Diagnostic strings the runtime may set on `reason` (log-only, do not branch on them):
*
* - `historyTrimmed`: the historical ops required to resolve the mark are no longer retained.
*/
readonly kind: "unresolvable";
readonly reason?: string;
};

/**
* The data captured for a version mark. `pending` when the captured edit is local and not yet sequenced
* (resolve it later via {@link IVersionMarkResolver.resolve}); `resolved` when there is no in-flight local
* work, so the mark already points at a durable sequence number. The app packs its own stored record from
* this — the runtime does not define the stored locator shape.
*
* @legacy @beta
*/
export type VersionMarkCapture =
| {
readonly kind: "pending";
readonly batchId: string;
readonly sequenceNumberLowerBound: number;
}
| {
readonly kind: "resolved";
readonly sequenceNumber: number;
readonly timestamp?: number;
};

/**
* Runtime-owned resolver for app-stored version mark locators.
*
* @legacy @beta
*/
export interface IVersionMarkResolver {
/**
Comment thread
lindsnguyen marked this conversation as resolved.
* Captures a version mark at the current point. Seals the current outbound batch first (so a just-made
* local edit has a stable `batchId`, which is only assigned when a batch is flushed), then returns the
* mark data atomically: a `pending` capture (`batchId` + `sequenceNumberLowerBound`) when there is an
* unacked local batch, or a `resolved` capture (`sequenceNumber` + the last processed op's server
* `timestamp`) when there is no in-flight local work. The timestamp property is optional both for
* compatibility with previously stored captures and because it is `undefined` when neither a last
* processed message nor a last-summary message is available.
*
* @remarks Sealing the batch is a side effect (it submits the current batch), so capture at savepoint
* boundaries, not per keystroke. Do not call during manual batch accumulation (for example inside
* `orderSequentially`): the flush is disallowed there and throws, which closes the container.
*
* @returns The pending batch identity and inclusive sequence number lower bound, or the current sequence
* number and corresponding op timestamp (when available) when there is no pending local batch.
*/
sealAndCaptureVersionMark(): VersionMarkCapture;
/**
* Resolves a pending mark's batchId to a global sequence number (`sequenceNumberLowerBound` is the
* inclusive lower bound for a history read). A `resolved` sequence number feeds the loader's
* `loadContainerToSequenceNumber`.
*
* @param batchId - The stable identity of the pending batch.
* @param sequenceNumberLowerBound - The inclusive lower bound for the historical op search.
* @returns The resolved sequence number and server timestamp, or a result indicating that the batch
* remains pending or can no longer be resolved.
*/
resolve(batchId: string, sequenceNumberLowerBound: number): Promise<ResolveResult>;
/**
* Subscribes to inbound batch sequencing: fires `(batchId, sequenceNumber, timestamp)` per batch so any
* connected client can promote a matching pending mark. Returns an unsubscribe function.
*
* @param listener - Called with the stable batch identity, its final sequence number, and the final op's
* server timestamp.
* @returns A function that unsubscribes the listener.
*/
onBatchSequenced(
listener: (batchId: string, sequenceNumber: number, timestamp?: number) => void,
): () => void;
}
Original file line number Diff line number Diff line change
Expand Up @@ -387,8 +387,10 @@ export type ResolveResult = {
readonly timestamp?: number;
} | {
readonly kind: "pending";
readonly reason?: string;
} | {
readonly kind: "unresolvable";
readonly reason?: string;
};

// @beta @legacy
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -382,8 +382,10 @@ export type ResolveResult = {
readonly timestamp?: number;
} | {
readonly kind: "pending";
readonly reason?: string;
} | {
readonly kind: "unresolvable";
readonly reason?: string;
};

// @beta @legacy
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3005,14 +3005,15 @@ describe("Runtime", () => {

assert.deepEqual(
await containerRuntime.versionMarkResolver.resolve("targetBatch", 11),
{ kind: "pending" },
{ kind: "pending", reason: "historicalOpsUnavailable" },
"an older loader without fetchOps should not break the newer runtime",
);
logger.assertMatch([
{
eventName: "VersionMarkResolver:Resolve",
outcome: "pending",
path: "noReader",
reason: "historicalOpsUnavailable",
},
]);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,10 @@ describe("VersionMarkResolver", () => {
describe("resolve - no reader wired", () => {
it("reports an unknown batchId as pending when no reader is available", async () => {
const resolver = makeResolver();
assert.deepEqual(await resolver.resolve("missing_[1]", 0), { kind: "pending" });
assert.deepEqual(await resolver.resolve("missing_[1]", 0), {
kind: "pending",
reason: "historicalOpsUnavailable",
});
});
});

Expand Down Expand Up @@ -628,6 +631,7 @@ describe("VersionMarkResolver", () => {

assert.deepEqual(await resolver.resolve(generateBatchId("missing", 1), 5), {
kind: "pending",
reason: "awaitingSequence",
});
});
});
Expand All @@ -641,6 +645,7 @@ describe("VersionMarkResolver", () => {
const resolver = makeResolver({ reader, currentSequenceNumber: 12 });
assert.deepEqual(await resolver.resolve(generateBatchId("missing", 9), 1), {
kind: "unresolvable",
reason: "historyTrimmed",
});
});

Expand All @@ -650,6 +655,7 @@ describe("VersionMarkResolver", () => {
const resolver = makeResolver({ reader, currentSequenceNumber: 20 });
assert.deepEqual(await resolver.resolve(generateBatchId("missing", 9), 1), {
kind: "unresolvable",
reason: "historyTrimmed",
});
});

Expand All @@ -660,6 +666,7 @@ describe("VersionMarkResolver", () => {
const resolver = makeResolver({ reader, currentSequenceNumber: 6 });
assert.deepEqual(await resolver.resolve(generateBatchId("missing", 9), 6), {
kind: "unresolvable",
reason: "historyTrimmed",
});
});

Expand All @@ -674,6 +681,7 @@ describe("VersionMarkResolver", () => {
const resolver = makeResolver({ reader, currentSequenceNumber: 7 });
assert.deepEqual(await resolver.resolve(generateBatchId("missing", 9), 6), {
kind: "pending",
reason: "awaitingSequence",
});
});

Expand All @@ -683,6 +691,7 @@ describe("VersionMarkResolver", () => {
const resolver = makeResolver({ reader, currentSequenceNumber: 5 });
assert.deepEqual(await resolver.resolve(generateBatchId("missing", 9), 6), {
kind: "pending",
reason: "awaitingSequence",
});
});

Expand Down Expand Up @@ -870,7 +879,10 @@ describe("VersionMarkResolver", () => {
minimumSequenceNumber = 8;
resolver.processInboundBatch("c_[3]", 12, 12000);

assert.deepEqual(await resolver.resolve("a_[1]", 0), { kind: "pending" });
assert.deepEqual(await resolver.resolve("a_[1]", 0), {
kind: "pending",
reason: "historicalOpsUnavailable",
});
assert.deepEqual(await resolver.resolve("b_[2]", 0), {
kind: "resolved",
sequenceNumber: 10,
Expand Down Expand Up @@ -934,12 +946,14 @@ describe("VersionMarkResolver", () => {
const resolver = makeResolver({ logger });
assert.deepEqual(await resolver.resolve(generateBatchId("missing", 1), 5), {
kind: "pending",
reason: "historicalOpsUnavailable",
});
logger.assertMatch([
{
eventName: "Resolve",
outcome: "pending",
path: "noReader",
reason: "historicalOpsUnavailable",
},
]);
});
Expand Down
Loading
Loading