Skip to content

Add point-in-time container loading by sequence number#27669

Draft
sonalideshpandemsft wants to merge 8 commits into
microsoft:mainfrom
sonalideshpandemsft:load/odsp-vh
Draft

Add point-in-time container loading by sequence number#27669
sonalideshpandemsft wants to merge 8 commits into
microsoft:mainfrom
sonalideshpandemsft:load/odsp-vh

Conversation

@sonalideshpandemsft

@sonalideshpandemsft sonalideshpandemsft commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

This PR threads a point-in-time load target through the loader stack using an alpha API:

  • Adds LoaderHeader.sequenceNumber support for point-in-time load targets.
  • Adds alpha ILoadContainerToSequenceNumberProps and loadContainerToSequenceNumber.
  • Keeps loadExistingContainer as the normal/latest-load helper and does not add historical-load props to its beta API.
  • Passes the target through Loader.resolve, Container.load, SerializedStateManager.fetchSnapshot, and ISnapshotFetchOptionsAlpha.
  • Uses an internal "sequenceNumber" load mode so the container can replay to the requested target, pause inbound/outbound queues, and return a read-only historical view.
  • Rejects clearly when the requested target cannot be reached because the required trailing ops are unavailable, rather than waiting indefinitely.
  • Preserves the existing loadContainerPaused path as a separate internal helper; it does not use the new sequence-number load mode.
  • Adds alpha availability APIs for checking whether a point in document history appears materializable before attempting a load.

For ODSP, this adds point-in-time base snapshot selection. When loadToSequenceNumber is supplied, ODSP skips the latest snapshot path, lists recent versions without cache, reads candidate snapshot attributes, and selects the closest usable base snapshot at or before the requested target. If no usable base snapshot exists, ODSP fails clearly instead of returning latest.

The availability probe is exposed through the standalone alpha IPointInTimeMaterializationStorageService capability. ODSP uses it to report whether a point appears materializable, is missing a base version, is missing required replay ops, is inaccessible due to permissions, or cannot be determined.

@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Hi! Thank you for opening this PR. Want me to review it?

Based on the diff (1785 lines, 21 files), I've queued these reviewers:

  • Correctness — logic errors, race conditions, lifecycle issues
  • Security — vulnerabilities, secret exposure, injection
  • API Compatibility — breaking changes, release tags, type design
  • Performance — algorithmic regressions, memory leaks
  • Testing — coverage gaps, hollow tests

How this works

  • Adjust the reviewer set by ticking/unticking boxes above. Reviewer toggles alone don't trigger anything.

  • Tick Start review below to dispatch the review fleet.

  • After review finishes, tick Start review again to request another run — it auto-resets after each dispatch.

  • This comment updates as new commits land; your reviewer selections are preserved.

  • Start review

@anthony-murphy

Copy link
Copy Markdown
Contributor

packages/loader/container-loader/src/createAndLoadContainerUtils.ts:310

Deep Review: On the generic loadExistingContainer path, the returned container never stops at loadToSequenceNumber — it loads from an older snapshot and then catches up to latest, which is not a point-in-time view.

loadExistingContainer injects headers[LoaderHeader.sequenceNumber] = alphaProps.loadToSequenceNumber (createAndLoadContainerUtils.ts:310-312) but sets no loadMode/opsBeforeReturn. In Container.load, loadToSequenceNumber is consumed only by serializedStateManager.fetchSnapshot for base-snapshot selection (container.ts:1644-1651); the op-processing switch (loadMode.opsBeforeReturn) still branches only on undefined | "cached" | "all" — there is no branch that halts at loadToSequenceNumber. Only loadContainerPaused installs the opHandler/pauseContainer logic that stops at the target (loadPaused.ts:114-120). So a host on the documented generic path gets an older base snapshot plus catch-up to latest, while the changeset promises the loader will "materialize before returning the container."

PR #16152 formalized requiring opsBeforeReturn: "sequenceNumber" whenever LoaderHeader.sequenceNumber is defined precisely because otherwise the header "will essentially 'win'". This PR re-threads the header without that coupling or any validation.

Question for the author: is the returned container on the generic path intended to stop at the target, or is exact point-in-time materialization only supported via loadContainerPaused? The answer determines the fix:

  • (a) Restore an explicit wait-to-target load mode so Container.load halts op processing once loadToSequenceNumber is reached (with validation of the loadMode/target combination, as [Data Migration] Add APIs to load a paused container at a specified sequence number #16152 did), plus an end-to-end test asserting the returned container is at the target; or
  • (b) Narrow the API/docs so only loadContainerPaused promises exact materialization and ILoadExistingContainerPropsAlpha does not expose loadToSequenceNumber on the generic surface.

Update the changeset headline example in .changeset/add-historical-load-target.md to match whichever contract is chosen.

onClose = (error?: IErrorBase): void => reject(error);

// We need to setup a listener to stop op processing once we reach the desired sequence number (if specified).
opHandler = (): void => {

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: The central new behavior of this PR has no test coverage. The added tests exercise only ODSP base-snapshot selection (fetchSnapshot.spec.ts:345-468) and option plumbing (serializedStateManager.spec.ts:321-366).

There is no test for loadContainerPaused replaying from a historical base snapshot and pausing exactly at loadToSequenceNumber (loadPaused.ts:114-120), nor for Container.canMaterializePointInTime delegating through the storage-adapter chain (ContainerStorageAdapterRetriableDocumentStorageService/ProtocolTreeStorageService → driver, container.ts:678-682). This is exactly the untested path where the generic-contract gap in the other thread lives — a target-assertion test would have caught it.

Please add:

  • A test driving loadContainerPaused with a mocked delta stream asserting the container pauses at the target, including the "already at target" and "snapshot newer than target" branches.
  • A test asserting container-level canMaterializePointInTime returns unknownUnavailable when the underlying driver lacks the method.

(The opHandler dropped the loadToSequenceNumber !== undefined guard at loadPaused.ts:114-119, but the early return at loadPaused.ts:86-93 keeps that safe — the removed guard is not itself a live bug; the untested-path concern stands independently.)

@sonalideshpandemsft

Copy link
Copy Markdown
Contributor Author

packages/loader/container-loader/src/createAndLoadContainerUtils.ts:310

Deep Review: On the generic loadExistingContainer path, the returned container never stops at loadToSequenceNumber — it loads from an older snapshot and then catches up to latest, which is not a point-in-time view.

loadExistingContainer injects headers[LoaderHeader.sequenceNumber] = alphaProps.loadToSequenceNumber (createAndLoadContainerUtils.ts:310-312) but sets no loadMode/opsBeforeReturn. In Container.load, loadToSequenceNumber is consumed only by serializedStateManager.fetchSnapshot for base-snapshot selection (container.ts:1644-1651); the op-processing switch (loadMode.opsBeforeReturn) still branches only on undefined | "cached" | "all" — there is no branch that halts at loadToSequenceNumber. Only loadContainerPaused installs the opHandler/pauseContainer logic that stops at the target (loadPaused.ts:114-120). So a host on the documented generic path gets an older base snapshot plus catch-up to latest, while the changeset promises the loader will "materialize before returning the container."

PR #16152 formalized requiring opsBeforeReturn: "sequenceNumber" whenever LoaderHeader.sequenceNumber is defined precisely because otherwise the header "will essentially 'win'". This PR re-threads the header without that coupling or any validation.

Question for the author: is the returned container on the generic path intended to stop at the target, or is exact point-in-time materialization only supported via loadContainerPaused? The answer determines the fix:

  • (a) Restore an explicit wait-to-target load mode so Container.load halts op processing once loadToSequenceNumber is reached (with validation of the loadMode/target combination, as [Data Migration] Add APIs to load a paused container at a specified sequence number #16152 did), plus an end-to-end test asserting the returned container is at the target; or
  • (b) Narrow the API/docs so only loadContainerPaused promises exact materialization and ILoadExistingContainerPropsAlpha does not expose loadToSequenceNumber on the generic surface.

Update the changeset headline example in .changeset/add-historical-load-target.md to match whichever contract is chosen.

This change is for "generic path intended to stop at the target". By generic path, I mean the generic alpha historical API path:
loadExisitingContainer({ loadToSequenceNumber }), not loadExisitingContainer(...)
The intended contract is:

  • loadExisitingContainer(...) with no loadToSequenceNumber behaves normally and loads the latest
  • loadExisitingContainer({ loadToSequenceNumber }) is an explicit point-in-time request, so it must stop at the target sequence number.
  • loadContainerPaused(..., loadToSequenceNumber) also stops at the target

* If the underlying driver cannot answer, implementations should return `notAvailable` rather than claiming
* the point is unavailable for a specific reason.
*/
canMaterializePointInTime(

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.

i think this would be better as a free function, rather than being built into the IContainer interface if possible

type IContainerLoadDriverProps,
type ICreateAndLoadContainerProps,
type ICreateDetachedContainerProps,
type ILoadExistingContainerPropsAlpha,

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.

i do not feel like this should just be added to the existing load flow. it is fundamentally different from normal loading, and i think should have its own independent path with its own types. pieces can be reused where it makes sense.

also is the plan for these version to always be readonly? like do with local versions?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I figured it's best to keep it readonly (similar to local versions) rather than open this old point and keep editing from there. My thinking was, for NITL, copilot changes are auto-applied, and this change lets us inspect the document exactly at the sequence number produced by that copilot change, even if more edits happen afterward.

example:

  • seq 10: copilot auto applies change A
  • seq 11-20: other users make more edits

Historical load reconstructs state at seq 10.
If it writes, the new op is not inserted at seq 11.
It gets appended at seq 21, after edits 11-20.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Also, edits from a old point would be a larger change? but I'm open to thoughts around this

* Alpha storage service extension for point-in-time materialization probes.
* @legacy @alpha
*/
export interface IDocumentStorageServiceAlpha extends IDocumentStorageService {

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.

i do no think this should be combined with IDocumentStorageService. it should be a separate interface, as the load path and usage is fundamentally different. it can still be alpha, it can still be in driver definitions, it should just be separate from the existing interfaces

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.

this is partially due to the fact that we do not expect other drivers to support this flow generally

);
const alphaProps = loadExistingContainerProps as ILoadExistingContainerPropsAlpha;
const headers = { ...loadExistingContainerProps.request.headers };
if (alphaProps.loadToSequenceNumber !== undefined) {

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.

this is probably the wrong mechanism to do this. i think your want to be more like frozen load

@anthony-murphy

Copy link
Copy Markdown
Contributor

Deep Review: Thanks for confirming — loadExistingContainer({ loadToSequenceNumber }) is meant to stop at the target. That sharpens rather than closes the earlier concern: because the path is required to stop at the target, it now hangs uncancellably when the target is unreachable (target in the future, ops not yet flushed, or a sustained 429), and the connect() fallback in loadContainerPaused becomes unreachable dead code. I've posted the full mechanism and a recommended fix on a new inline thread on container.ts, anchored at the new case "sequenceNumber" wait. The decision that determines the fix: when the requested sequence number isn't yet available, should the load reject with a clear error, enforce the canMaterializePointInTime preflight, or abort/timeout into the wait?

);
break;
}
case "sequenceNumber": {

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: This targeted-load path can hang uncancellably when the requested loadToSequenceNumber is unreachable, and the connect() fallback in loadContainerPaused is now dead code.

The paused/targeted load forces opsBeforeReturn: "sequenceNumber" with deltaConnection: "none" (loadPaused.ts:63-66). The new case "sequenceNumber" builds opsBeforeReturnP = Promise.all([attachDeltaManagerOpHandler(..., "sequenceNumber", ...), targetReachedP]), and targetReachedP resolves only when an op with sequenceNumber >= loadToSequenceNumber is processed. Because deltaConnection is "none", DeltaManager drains delta storage bounded to loadToSequenceNumber + 1 and stops without opening the WebSocket. If storage cannot supply an op reaching the target — target in the future, ops not yet flushed, or a sustained 429 — targetReached() is never called and await opsBeforeReturnP blocks forever. No AbortSignal is threaded into Container.load; the signal param in loadContainerPaused (loadPaused.ts:126) is wired only into the opHandler/connect() block.

Separately, after loadExistingContainer(...) returns, Container.load has already paused at the target, so lastProcessedSequenceNumber === loadToSequenceNumber and the "already there" early return (loadPaused.ts:88-96) always fires — making the opHandler + container.connect() block (loadPaused.ts:107-158), including the comment explaining why connect() is needed to fetch ops, unreachable dead code.

Your clarification that loadExistingContainer({ loadToSequenceNumber }) is intended to stop at the target confirms this is a robustness gap, not an ambiguity: a path that must stop at the target hangs when the target is unreachable.

Recommendation: decide and implement the unreachable-target behavior — (a) detect delta-storage exhaustion (the targeted fetch drains without reaching loadToSequenceNumber) and reject with a clear "requested sequence number is not yet available" error; and/or (b) thread the AbortSignal/a timeout into the "sequenceNumber" wait; and/or (c) gate the load behind the new canMaterializePointInTime availability probe. Then remove the now-unreachable opHandler + connect() block (loadPaused.ts:107-158) and its stale comment, or restore a path that keeps it reachable. Add an end-to-end test asserting the targeted load fails clearly rather than hangs when the target exceeds available ops.

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: Related to the hang above, the same "sequenceNumber" / deltaConnection: "none" path leaves the read-only historical contract enforced only by convention. pauseContainer() pauses the queues, but handleDeltaConnectionArg("none") is a bare break that never clears inboundQueuePausedFromInit — unlike the undefined/"delayed" cases, which set it false. A later Container.connect()resumeInternal() sees inboundQueuePausedFromInit === true, resumes inbound/inboundSignal, and advances the container past loadToSequenceNumber — so a host can silently walk the "historical" container forward off the target. The predecessor loadContainerPaused deliberately calls container.forceReadonly?.(true); this path never does, and the new tests assert only paused queues, not readonly state.

You confirmed readonly is the intended contract (like local versions — a later write appends after latest rather than at the historical point). Convention-only pausing doesn't enforce that intent. Suggest failing closed on the historical path: call forceReadonly(true) on the returned container mirroring loadContainerPaused, and reject connect()/getPendingLocalState() on historical containers with a typed UsageError, while still allowing local read/UI interaction. Add a test asserting readonly state and that connect() does not advance the sequence number.

@github-actions

Copy link
Copy Markdown
Contributor

🔗 No broken links found! ✅

Your attention to detail is admirable.

linkcheck output

1: starting server using command "npm run serve -- --no-open"
and when url "[ 'http://127.0.0.1:3000' ]" is responding with HTTP status code 200
running tests using command "npm run check-links"


> fluid-framework-website@0.0.0 serve
> docusaurus serve --no-open

[SUCCESS] Serving "build" directory at: http://localhost:3000/

> fluid-framework-website@0.0.0 check-links
> linkcheck http://localhost:3000 --skip-file skipped-urls.txt

Crawling...

Stats:
  294296 links
    1937 destination URLs
    2187 URLs ignored
       0 warnings
       0 errors


@github-actions

Copy link
Copy Markdown
Contributor

Bundle size comparison

Base commit: 006b9fcdfe1a7083da88f7b2faf010ec33cad26c
Head commit: 36c41c54d58b1b9f580f54334538ffe0393a2eaa

Notable changes

  • 🔴 azureClient.js: parsed 619098 → 621144 (+2046), gzip 164826 → 165469 (+643)
  • 🔴 odspClient.js: parsed 591824 → 597105 (+5281), gzip 158912 → 160372 (+1460)
  • 🔴 fluidFramework.js: parsed 392708 → 394163 (+1455), gzip 111481 → 111941 (+460)
  • 🔴 sharedTree.js: parsed 382095 → 383543 (+1448), gzip 108869 → 109321 (+452)
  • 🔴 loader.js: parsed 145256 → 147241 (+1985), gzip 39063 → 39644 (+581)
  • 🔴 odspDriver.js: parsed 104329 → 107619 (+3290), gzip 32646 → 33452 (+806)
Per-bundle deltas

@fluid-example/bundle-size-tests

  • 🔴 azureClient.js: parsed 619098 → 621144 (+2046), gzip 164826 → 165469 (+643)
  • 🔴 odspClient.js: parsed 591824 → 597105 (+5281), gzip 158912 → 160372 (+1460)
  • aqueduct.js: parsed 525587 → 525622 (+35), gzip 140710 → 140732 (+22)
  • 🔴 fluidFramework.js: parsed 392708 → 394163 (+1455), gzip 111481 → 111941 (+460)
  • 🔴 sharedTree.js: parsed 382095 → 383543 (+1448), gzip 108869 → 109321 (+452)
  • containerRuntime.js: parsed 303937 → 303951 (+14), gzip 83213 → 83206 (-7)
  • sharedString.js: parsed 175984 → 175991 (+7), gzip 49445 → 49453 (+8)
  • experimentalSharedTree.js: parsed 160798 → 160798 (0), gzip 45804 → 45804 (0)
  • matrix.js: parsed 159845 → 159852 (+7), gzip 45411 → 45418 (+7)
  • 🔴 loader.js: parsed 145256 → 147241 (+1985), gzip 39063 → 39644 (+581)
  • 🔴 odspDriver.js: parsed 104329 → 107619 (+3290), gzip 32646 → 33452 (+806)
  • directory.js: parsed 66616 → 66623 (+7), gzip 18532 → 18541 (+9)
  • 748.js: parsed 58793 → 58793 (0), gzip 17827 → 17827 (0)
  • map.js: parsed 46709 → 46716 (+7), gzip 14310 → 14318 (+8)
  • odspPrefetchSnapshot.js: parsed 45642 → 45656 (+14), gzip 15277 → 15285 (+8)
  • 985.js: parsed 44491 → 44491 (0), gzip 13726 → 13726 (0)
  • summarizerDelayLoadedModule.js: parsed 30749 → 30749 (0), gzip 7753 → 7753 (0)
  • socketModule.js: parsed 26476 → 26483 (+7), gzip 7885 → 7895 (+10)
  • createNewModule.js: parsed 12480 → 12480 (0), gzip 4786 → 4786 (0)
  • summaryModule.js: parsed 3797 → 3797 (0), gzip 1860 → 1860 (0)
  • connectionState.js: parsed 724 → 724 (0), gzip 429 → 429 (0)
  • sharedTreeAttributes.js: parsed 666 → 673 (+7), gzip 432 → 442 (+10)
  • debugAssert.js: parsed 429 → 429 (0), gzip 299 → 299 (0)
  • FluidFramework-HashFallback.js: parsed 422 → 422 (0), gzip 316 → 316 (0)

@anthony-murphy

Copy link
Copy Markdown
Contributor

Deep Review

Reviewed commit 36c41c5 on 2026-07-10.

Readiness: 5/10 — MAKING PROGRESS

Not ready for sign-off, but the core architecture is validated — no rework required. Three contained correctness gaps remain, all flagged inline: the read-only historical contract is enforced only by convention (a host can connect() past the target), the targeted op-fetch wait can't be cancelled and hangs when the target is unreachable, and the central replay/pause-at-target behavior has no test coverage. Once those land, only Tier 3 polish is left.

Path to Ready

  • Resolve inline threads
  • Update the .changeset/add-historical-load-target.md headline example to match the finalized stops-at-target contract
  • Regenerate the container-loader api-report if the alpha surface changes while addressing the above

Context for Reviewers

For human reviewer
Review history (2 prior reviews)
  • a963920 2026-07-10 · 2/10 — targeted stop-at-target path hangs uncancellably when the target is unreachable
  • 40c63cc 2026-07-10 · 2/10 — generic path never stops at target; core replay/pause path untested

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants