diff --git a/.changeset/version-mark-resolver-access-point.md b/.changeset/version-mark-resolver-access-point.md new file mode 100644 index 000000000000..f7ad5ce60962 --- /dev/null +++ b/.changeset/version-mark-resolver-access-point.md @@ -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`](https://fluidframework.com/docs/api/container-runtime-definitions/iversionmarkresolver-interface), [`ResolveResult`](https://fluidframework.com/docs/api/container-runtime-definitions/resolveresult-typealias), [`VersionMarkCapture`](https://fluidframework.com/docs/api/container-runtime-definitions/versionmarkcapture-typealias)), which move to `@fluidframework/container-runtime-definitions`. Hosts obtain the resolver from the runtime instead of the concrete `ContainerRuntime` class. + +`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. diff --git a/packages/runtime/container-runtime-definitions/api-report/container-runtime-definitions.legacy.beta.api.md b/packages/runtime/container-runtime-definitions/api-report/container-runtime-definitions.legacy.beta.api.md index 72470256e7c7..ebc038c7d9a2 100644 --- a/packages/runtime/container-runtime-definitions/api-report/container-runtime-definitions.legacy.beta.api.md +++ b/packages/runtime/container-runtime-definitions/api-report/container-runtime-definitions.legacy.beta.api.md @@ -25,6 +25,7 @@ export interface IContainerRuntime extends IProvideFluidDataStoreRegistry, ICont readonly scope: FluidObject; // (undocumented) readonly storage: IContainerStorageService; + readonly versionMarkResolver: IVersionMarkResolver; } // @beta @sealed @legacy (undocumented) @@ -94,6 +95,26 @@ export interface ISummarizerObservabilityProps { numUnsummarizedRuntimeOps: number; } +// @beta @sealed @legacy +export interface IVersionMarkResolver { + onBatchSequenced(listener: (batchId: string, sequenceNumber: number, timestamp?: number) => void): () => void; + resolve(batchId: string, sequenceNumberLowerBound: number): Promise; + 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 = /** @@ -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) ``` diff --git a/packages/runtime/container-runtime-definitions/package.json b/packages/runtime/container-runtime-definitions/package.json index 059c38229af3..17e3b714065c 100644 --- a/packages/runtime/container-runtime-definitions/package.json +++ b/packages/runtime/container-runtime-definitions/package.json @@ -91,7 +91,11 @@ "typescript": "~6.0.3" }, "typeValidation": { - "broken": {}, + "broken": { + "Interface_IContainerRuntimeWithResolveHandle_Deprecated": { + "forwardCompat": false + } + }, "entrypoint": "legacy" } } diff --git a/packages/runtime/container-runtime-definitions/src/containerRuntime.ts b/packages/runtime/container-runtime-definitions/src/containerRuntime.ts index 10ce0e32ed72..398b56d37469 100644 --- a/packages/runtime/container-runtime-definitions/src/containerRuntime.ts +++ b/packages/runtime/container-runtime-definitions/src/containerRuntime.ts @@ -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 @@ -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; + /** * Indicates the attachment state of the container to a host service. */ diff --git a/packages/runtime/container-runtime-definitions/src/index.ts b/packages/runtime/container-runtime-definitions/src/index.ts index be66642def4d..527a7f711328 100644 --- a/packages/runtime/container-runtime-definitions/src/index.ts +++ b/packages/runtime/container-runtime-definitions/src/index.ts @@ -41,3 +41,8 @@ export type { ISummarizerObservabilityProps, ISummarizerEvents, } from "./containerRuntime.js"; +export type { + IVersionMarkResolver, + ResolveResult, + VersionMarkCapture, +} from "./versionMarks.js"; diff --git a/packages/runtime/container-runtime-definitions/src/test/types/validateContainerRuntimeDefinitionsPrevious.generated.ts b/packages/runtime/container-runtime-definitions/src/test/types/validateContainerRuntimeDefinitionsPrevious.generated.ts index aa64ce92a150..ad656e37a3c7 100644 --- a/packages/runtime/container-runtime-definitions/src/test/types/validateContainerRuntimeDefinitionsPrevious.generated.ts +++ b/packages/runtime/container-runtime-definitions/src/test/types/validateContainerRuntimeDefinitionsPrevious.generated.ts @@ -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> /* diff --git a/packages/runtime/container-runtime-definitions/src/versionMarks.ts b/packages/runtime/container-runtime-definitions/src/versionMarks.ts new file mode 100644 index 000000000000..b35ab64cfbe8 --- /dev/null +++ b/packages/runtime/container-runtime-definitions/src/versionMarks.ts @@ -0,0 +1,125 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +/** + * 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. + * + * @remarks This interface is implemented only by the Fluid runtime. Hosts obtain it from + * {@link IContainerRuntime.versionMarkResolver} and consume it, never implement it. + * + * @sealed + * @legacy @beta + */ +export interface IVersionMarkResolver { + /** + * 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; + /** + * 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; +} diff --git a/packages/runtime/container-runtime/api-report/container-runtime.legacy.alpha.api.md b/packages/runtime/container-runtime/api-report/container-runtime.legacy.alpha.api.md index 037826e46b66..f5c99129b660 100644 --- a/packages/runtime/container-runtime/api-report/container-runtime.legacy.alpha.api.md +++ b/packages/runtime/container-runtime/api-report/container-runtime.legacy.alpha.api.md @@ -338,7 +338,7 @@ export interface IUploadSummaryResult extends Omit void): () => void; resolve(batchId: string, sequenceNumberLowerBound: number): Promise; @@ -387,8 +387,10 @@ export type ResolveResult = { readonly timestamp?: number; } | { readonly kind: "pending"; + readonly reason?: string; } | { readonly kind: "unresolvable"; + readonly reason?: string; }; // @beta @legacy diff --git a/packages/runtime/container-runtime/api-report/container-runtime.legacy.beta.api.md b/packages/runtime/container-runtime/api-report/container-runtime.legacy.beta.api.md index 456376f857d0..5ce3f8626014 100644 --- a/packages/runtime/container-runtime/api-report/container-runtime.legacy.beta.api.md +++ b/packages/runtime/container-runtime/api-report/container-runtime.legacy.beta.api.md @@ -338,7 +338,7 @@ export interface IUploadSummaryResult extends Omit void): () => void; resolve(batchId: string, sequenceNumberLowerBound: number): Promise; @@ -382,8 +382,10 @@ export type ResolveResult = { readonly timestamp?: number; } | { readonly kind: "pending"; + readonly reason?: string; } | { readonly kind: "unresolvable"; + readonly reason?: string; }; // @beta @legacy diff --git a/packages/runtime/container-runtime/src/test/containerRuntime.spec.ts b/packages/runtime/container-runtime/src/test/containerRuntime.spec.ts index 408170469c91..04f717ce900d 100644 --- a/packages/runtime/container-runtime/src/test/containerRuntime.spec.ts +++ b/packages/runtime/container-runtime/src/test/containerRuntime.spec.ts @@ -3005,7 +3005,7 @@ 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([ @@ -3013,6 +3013,7 @@ describe("Runtime", () => { eventName: "VersionMarkResolver:Resolve", outcome: "pending", path: "noReader", + reason: "historicalOpsUnavailable", }, ]); }); diff --git a/packages/runtime/container-runtime/src/test/versionMarks/versionMarkResolver.spec.ts b/packages/runtime/container-runtime/src/test/versionMarks/versionMarkResolver.spec.ts index 0dcb2e0074aa..8b62fa8a2251 100644 --- a/packages/runtime/container-runtime/src/test/versionMarks/versionMarkResolver.spec.ts +++ b/packages/runtime/container-runtime/src/test/versionMarks/versionMarkResolver.spec.ts @@ -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", + }); }); }); @@ -628,6 +631,7 @@ describe("VersionMarkResolver", () => { assert.deepEqual(await resolver.resolve(generateBatchId("missing", 1), 5), { kind: "pending", + reason: "awaitingSequence", }); }); }); @@ -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", }); }); @@ -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", }); }); @@ -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", }); }); @@ -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", }); }); @@ -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", }); }); @@ -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, @@ -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", }, ]); }); diff --git a/packages/runtime/container-runtime/src/versionMarks/DEV.md b/packages/runtime/container-runtime/src/versionMarks/DEV.md index 12713fa87d0e..f2d517047ebc 100644 --- a/packages/runtime/container-runtime/src/versionMarks/DEV.md +++ b/packages/runtime/container-runtime/src/versionMarks/DEV.md @@ -89,10 +89,10 @@ are ignored until they are applied into the current session's pending queue. promotion is recoverable — the app can still resolve that mark later via `resolve()`'s history scan — so a listener fault logs and continues rather than faulting the container. -Host exposure: `ContainerRuntime` exposes an `@internal` `versionMarkResolver` getter backed by the concrete -`versionMarkResolverInternal`. An app gets it from the runtime instance passed to `provideEntryPoint`, or exposes it -from its own entryPoint. A future public API may move this onto container-runtime definitions rather than the concrete -runtime class. +Host exposure: `ContainerRuntime` exposes a `versionMarkResolver` getter backed by the concrete +`versionMarkResolverInternal`, and the resolver is now exposed on the `@legacy @beta` `IContainerRuntime` interface (in +`@fluidframework/container-runtime-definitions`). An app gets it from the runtime instance passed to +`provideEntryPoint`, or exposes it from its own entryPoint. ### Capture implementation @@ -119,7 +119,7 @@ state between flushing, reading the sequence number, and reading the batch id. An app (e.g. the Loop/office-bohemia host) consumes a small `@legacy @beta` surface: -- Get the resolver: `ContainerRuntime.versionMarkResolver` -> `IVersionMarkResolver`. +- Get the resolver: `IContainerRuntime.versionMarkResolver` -> `IVersionMarkResolver`. - `IVersionMarkResolver` methods: `sealAndCaptureVersionMark()` -> `VersionMarkCapture` (seals the batch and returns the locator data atomically), `onBatchSequenced(listener)` (live promotion), `resolve(batchId, sequenceNumberLowerBound)` -> `ResolveResult` (load-time sweep / restore). @@ -368,10 +368,10 @@ so all apps can feed one shared dashboard. Design events around the questions a ### Events -| Event | When | Dimensions | Answers | -| -------------------------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Resolve` **(implemented)** | end of `resolve()` (via `finally`, so a thrown scan is still reported) | `outcome` (`resolved`\|`pending`\|`unresolvable`\|`error`), `path` (`session`\|`history`\|`noReader`), `durationMs`, `sequenceNumber` (when resolved) | success rate; how often history is needed; latency; `unresolvable` = data-loss KPI; `error` = the scan threw. `session` means the final result came from the live map, including a live resolution found by the post-history recheck. | -| `Capture` **(implemented — AB#80270)** | `sealAndCaptureVersionMark()` | `kind` (`pending`\|`resolved`) | capture volume; pending ratio | +| Event | When | Dimensions | Answers | +| -------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Resolve` **(implemented)** | end of `resolve()` (via `finally`, so a thrown scan is still reported) | `outcome` (`resolved`\|`pending`\|`unresolvable`\|`error`), `path` (`session`\|`history`\|`noReader`), `durationMs`, `sequenceNumber` (when resolved), `reason` (the diagnostic string when the result carries one) | success rate; how often history is needed; latency; `unresolvable` = data-loss KPI; `error` = the scan threw; `reason` = why a mark did not resolve. `session` means the final result came from the live map, including a live resolution found by the post-history recheck. | +| `Capture` **(implemented — AB#80270)** | `sealAndCaptureVersionMark()` | `kind` (`pending`\|`resolved`) | capture volume; pending ratio | ### Correlation and the app funnel (planned) @@ -603,13 +603,13 @@ known sequence number and exercise loading. They do not create that sequence num ### API and design follow-ups -- **Loader/runtime compatibility pattern (resolved with Navin).** `fetchOps` is a new loader-provided API consumed by - runtime logic. Making the member optional and using its presence as the capability signal is the correct cross-layer - pattern; a separate `supportedFeatures` negotiation is unnecessary for a new API. Fluid supports a 12-month Runtime -> - Loader compatibility window, so a newer runtime must continue to work with an older loader that predates `fetchOps`. - The capability first shipped in client 2.115.0 at layer generation 9. Keep the optional branch through generation 20; - at generation 21 every supported loader is guaranteed to provide it, and AB#81034 tracks making the member required - and removing the branch. +- **Loader/runtime compatibility pattern.** `fetchOps` is a new loader-provided API consumed by runtime logic. Making + the member optional and using its presence as the capability signal is the correct cross-layer pattern; a separate + `supportedFeatures` negotiation is unnecessary for a new API. Fluid supports a 12-month Runtime -> Loader + compatibility window, so a newer runtime must continue to work with an older loader that predates `fetchOps`. The + capability first shipped in client 2.115.0 at layer generation 9. Keep the optional branch through generation 20; at + generation 21 every supported loader is guaranteed to provide it, and AB#81034 tracks making the member required and + removing the branch. - Consider merging `getCurrentPendingBatchId` into `flushPendingBatch` so sealing the batch returns its resulting `batchId`. This would keep the ordered flush-then-read operation inside one runtime hook instead of requiring the resolver to call two hooks in sequence. @@ -629,10 +629,10 @@ known sequence number and exercise loading. They do not create that sequence num open question is whether to instead extend the existing `batchEnd` event (on `IContainerRuntimeBaseEvents`) to expose the effective stable batch ID and reuse it, retiring `onBatchSequenced`. - **Case for consolidating (Mark's original point):** the new event is largely a subset of `batchEnd` plus one - generally-applicable field (the stable batch id). If API changes were free we would not add a second parallel - notification API; every extra public API is long-term surface we are then responsible for maintaining, documenting, - and evolving. Avoiding that cruft is the benefit. + **Case for consolidating:** the new event is largely a subset of `batchEnd` plus one generally-applicable field (the + stable batch id). If API changes were free we would not add a second parallel notification API; every extra public API + is long-term surface we are then responsible for maintaining, documenting, and evolving. Avoiding that cruft is the + benefit. **Case for keeping `onBatchSequenced` separate:** it is **not** a strict subset of `batchEnd` — its firing contract is correctness-load-bearing and differs in three ways: (1) it fires **only after pending-state validation**, so a @@ -682,24 +682,27 @@ substitute: its current contract means retained history was available but the ta proven gone. A later container load with a newer loader could resolve a mark that the old-loader pairing could not inspect. -Before production, choose and document one policy: - -- **Non-breaking clarification (preferred if sufficient):** add an optional reason to the existing `pending` member, - such as `reason?: "awaitingSequence" | "historicalOpsUnavailable"`. Existing consumers and older runtime results - remain valid, while an updated host can choose a different retry policy. -- **Clean distinct outcome:** add an `unavailable`/`indeterminate` result, or make a reason required. This is a breaking - change to the exported `@legacy @beta` union because exhaustive consumers must handle the new shape. It requires a - changeset, regenerated API reports, API Council approval, an allowed beta-break window, and office-bohemia partner - lead time/integration testing. -- **Reuse `unresolvable` (not recommended without redefining it):** this avoids a new union member, but would blur a - terminal retained-history result with a capability-limited result that may become resolvable after loading with a - newer loader. If selected, its contract and host retry behavior must be deliberately changed and documented. -- **Keep current behavior:** explicitly define no-reader `pending` as a conservative compatibility result and require - the host to bound retries or rely on live promotion. This avoids an API change but preserves the ambiguity. - -Whichever policy is selected, test both a current loader and an old-loader-shaped context with no `fetchOps`, and -document whether a host should retain and retry the mark after a later deployment/reload. Do not remove the optional -branch as part of this decision; that cleanup remains independently gated by AB#81034 and generation 21. +`ResolveResult` keeps its three existing `kind` values. The `pending` and `unresolvable` members also carry an optional +`reason?: string` with transient, log-only diagnostic context: + +- `pending` currently reports `awaitingSequence` or `historicalOpsUnavailable`. +- `unresolvable` currently reports `historyTrimmed`. + +Hosts drive behavior from `kind` and must not branch on or persist `reason`. Keeping `reason` as a plain string allows +new diagnostic values without creating a second discriminator; a state that requires different host behavior should be +represented by a new `kind`. `unresolvable` remains terminal. A host using an older loader may receive +`historicalOpsUnavailable` as the reason for `pending`, then successfully resolve the same mark after loading with a +newer loader. + +Before the changes are ready for production: + +1. Test both a current loader and an old-loader-shaped context with no `fetchOps`. +1. Document whether a host should retain and retry the mark after a later deployment or reload. +1. Update office-bohemia to log `reason` when it moves to the supported resolver access point, without changing behavior + based on the diagnostic value. + +Keep the optional `fetchOps` branch through layer generation 20. AB#81034 tracks making `fetchOps` required and removing +the branch at generation 21. ### Flush side effect and corner cases diff --git a/packages/runtime/container-runtime/src/versionMarks/index.ts b/packages/runtime/container-runtime/src/versionMarks/index.ts index f76fdb55ee53..84c4a892366f 100644 --- a/packages/runtime/container-runtime/src/versionMarks/index.ts +++ b/packages/runtime/container-runtime/src/versionMarks/index.ts @@ -3,11 +3,13 @@ * Licensed under the MIT License. */ +export type { + IVersionMarkResolver, + ResolveResult, + VersionMarkCapture, +} from "@fluidframework/container-runtime-definitions/internal"; export { VersionMarkResolver, - type IVersionMarkResolver, - type ResolveResult, - type VersionMarkCapture, type VersionMarkResolverRuntimeHooks, } from "./versionMarkResolver.js"; export { inboundVersionMarkUpdate, type InboundVersionMarkUpdate } from "./inboundBatch.js"; diff --git a/packages/runtime/container-runtime/src/versionMarks/versionMarkResolver.ts b/packages/runtime/container-runtime/src/versionMarks/versionMarkResolver.ts index fe1480390792..33c216d36851 100644 --- a/packages/runtime/container-runtime/src/versionMarks/versionMarkResolver.ts +++ b/packages/runtime/container-runtime/src/versionMarks/versionMarkResolver.ts @@ -3,6 +3,11 @@ * Licensed under the MIT License. */ +import type { + IVersionMarkResolver, + ResolveResult, + VersionMarkCapture, +} from "@fluidframework/container-runtime-definitions/internal"; import type { ISequencedDocumentMessage, IStream, @@ -29,87 +34,6 @@ export interface IHistoricalOpReader { ): Promise>; } -/** - * 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. - * - * @legacy @beta - */ -export type ResolveResult = - | { - readonly kind: "resolved"; - readonly sequenceNumber: number; - readonly timestamp?: number; - } - | { readonly kind: "pending" } - | { readonly kind: "unresolvable" }; - -/** - * 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 { - /** - * 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. - * - * @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; - /** - * 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; -} - /** * @internal */ @@ -214,16 +138,20 @@ export class VersionMarkResolver implements IVersionMarkResolver { let path: "session" | "history" | "noReader" = "history"; let outcome: ResolveResult["kind"] | "error" = "error"; let resolvedSequenceNumber: number | undefined; + let resolvedReason: string | undefined; try { // Fast path: batch sequenced live this session. const resolvedBatch = this.sessionResolutionFor(batchId); if (resolvedBatch === undefined) { const reader = this.hooks.getHistoricalOpReader?.(); if (reader === undefined) { - // No reader: the batch may still sequence live, so report pending. + // No reader: the batch may still sequence live, so report pending. The current loader + // does not provide the historical-op capability, so retrying with this pairing will not + // help (a later load with a capable loader may resolve it). path = "noReader"; outcome = "pending"; - return { kind: "pending" }; + resolvedReason = "historicalOpsUnavailable"; + return { kind: "pending", reason: "historicalOpsUnavailable" }; } // Otherwise scan history from the mark's reference point. path = "history"; @@ -240,6 +168,8 @@ export class VersionMarkResolver implements IVersionMarkResolver { outcome = result.kind; if (result.kind === "resolved") { resolvedSequenceNumber = result.sequenceNumber; + } else { + resolvedReason = result.reason; } return result; } @@ -256,6 +186,7 @@ export class VersionMarkResolver implements IVersionMarkResolver { ...(resolvedSequenceNumber === undefined ? {} : { sequenceNumber: resolvedSequenceNumber }), + ...(resolvedReason === undefined ? {} : { reason: resolvedReason }), }); } } @@ -330,20 +261,20 @@ export class VersionMarkResolver implements IVersionMarkResolver { const tip = this.hooks.getCurrentSequenceNumber(); if (from > tip) { // Nothing is sequenced at/after the mark's lower bound yet, so the batch cannot have landed. - return { kind: "pending" }; + return { kind: "pending", reason: "awaitingSequence" }; } if (firstScannedSequenceNumber === undefined) { // Empty read though ops should exist in `[from, tip]` → trimmed. ODSP-specific: a strict driver // empties a `from`-misaligned trimmed range (validateMessages); a return-from-earliest driver // would instead surface the trim via the `firstScannedSequenceNumber > from` branch below. - return { kind: "unresolvable" }; + return { kind: "unresolvable", reason: "historyTrimmed" }; } if (firstScannedSequenceNumber > from) { // A trim gap at the anchor: the mark's ops (at/after its lower bound) are gone. - return { kind: "unresolvable" }; + return { kind: "unresolvable", reason: "historyTrimmed" }; } // Ops are present from the lower bound and the batch is not among them: not yet sequenced. - return { kind: "pending" }; + return { kind: "pending", reason: "awaitingSequence" }; } public onBatchSequenced(