From 4bc400bbc00e42e568cd8143fc6ae92448a840d7 Mon Sep 17 00:00:00 2001 From: Daniel Madrid <105010181+dannimad@users.noreply.github.com> Date: Tue, 23 Dec 2025 11:21:51 -0600 Subject: [PATCH 1/8] snapshot refresher --- .../src/serializedStateManager.ts | 127 +-- .../container-loader/src/snapshotRefresher.ts | 181 ++++ .../src/test/snapshotRefresher.spec.ts | 790 ++++++++++++++++++ 3 files changed, 998 insertions(+), 100 deletions(-) create mode 100644 packages/loader/container-loader/src/snapshotRefresher.ts create mode 100644 packages/loader/container-loader/src/test/snapshotRefresher.spec.ts diff --git a/packages/loader/container-loader/src/serializedStateManager.ts b/packages/loader/container-loader/src/serializedStateManager.ts index 16b6b9145e11..44ee092f48cd 100644 --- a/packages/loader/container-loader/src/serializedStateManager.ts +++ b/packages/loader/container-loader/src/serializedStateManager.ts @@ -11,7 +11,7 @@ import type { ITelemetryBaseLogger, } from "@fluidframework/core-interfaces"; import type { IDisposable } from "@fluidframework/core-interfaces/internal"; -import { Timer, assert } from "@fluidframework/core-utils/internal"; +import { assert } from "@fluidframework/core-utils/internal"; import { FetchSource, type IDocumentStorageService, @@ -35,6 +35,7 @@ import { type ContainerStorageAdapter, type ISerializableBlobContents, } from "./containerStorageAdapter.js"; +import { SnapshotRefresher } from "./snapshotRefresher.js"; import { convertISnapshotToSnapshotWithBlobs, convertSnapshotToSnapshotInfo, @@ -127,7 +128,7 @@ export interface SerializedSnapshotInfo extends SnapshotWithBlobs { snapshotSequenceNumber: number; } -interface ISnapshotInfo { +export interface ISnapshotInfo { snapshotSequenceNumber: number; snapshotFetchedTime?: number | undefined; snapshot: ISnapshot | ISnapshotTree; @@ -144,27 +145,6 @@ interface ISerializerEvent extends IEvent { (event: "saved", listener: (dirty: boolean) => void): void; } -class RefreshPromiseTracker { - public get hasPromise(): boolean { - return this.#promise !== undefined; - } - public get Promise(): Promise | undefined { - return this.#promise; - } - constructor(private readonly catchHandler: (error: Error) => void) {} - - #promise: Promise | undefined; - setPromise(p: Promise): void { - if (this.hasPromise) { - throw new Error("Cannot set promise while promise exists"); - } - this.#promise = p.finally(() => { - this.#promise = undefined; - }); - p.catch(this.catchHandler); - } -} - /** * Helper class to manage the state of the container needed for proper serialization. * @@ -177,20 +157,8 @@ export class SerializedStateManager implements IDisposable { private readonly mc: MonitoringContext; private snapshotInfo: ISnapshotInfo | undefined; private latestSnapshot: ISnapshotInfo | undefined; - private readonly refreshTracker = new RefreshPromiseTracker( - // eslint-disable-next-line unicorn/consistent-function-scoping - (error) => - this.mc.logger.sendErrorEvent( - { - eventName: "RefreshLatestSnapshotFailed", - }, - error, - ), - ); private lastSavedOpSequenceNumber: number = 0; - private readonly refreshTimer: Timer | undefined; - private readonly snapshotRefreshTimeoutMs: number = 60 * 60 * 24 * 1000; - readonly #snapshotRefreshEnabled: boolean; + private readonly snapshotRefresher: SnapshotRefresher | undefined; #disposed: boolean = false; /** @@ -214,16 +182,17 @@ export class SerializedStateManager implements IDisposable { namespace: "serializedStateManager", }); - this.snapshotRefreshTimeoutMs = snapshotRefreshTimeoutMs ?? this.snapshotRefreshTimeoutMs; - - this.#snapshotRefreshEnabled = - this.offlineLoadEnabled && - (this.mc.config.getBoolean("Fluid.Container.enableOfflineSnapshotRefresh") ?? - this.mc.config.getBoolean("Fluid.Container.enableOfflineFull")) === true; - - this.refreshTimer = this.#snapshotRefreshEnabled - ? new Timer(this.snapshotRefreshTimeoutMs, () => this.tryRefreshSnapshot()) + this.snapshotRefresher = this.offlineLoadEnabled + ? new SnapshotRefresher( + subLogger, + this.storageAdapter, + this.offlineLoadEnabled, + this.supportGetSnapshotApi, + (snapshot: ISnapshotInfo) => this.handleSnapshotRefreshed(snapshot), + snapshotRefreshTimeoutMs, + ) : undefined; + containerEvent.on("saved", () => this.updateSnapshotAndProcessedOpsMaybe()); } public get disposed(): boolean { @@ -231,7 +200,7 @@ export class SerializedStateManager implements IDisposable { } dispose(): void { this.#disposed = true; - this.refreshTimer?.clear(); + this.snapshotRefresher?.dispose(); } private verifyNotDisposed(): void { @@ -245,8 +214,8 @@ export class SerializedStateManager implements IDisposable { * only intended to be used for testing purposes. * @returns The snapshot sequence number associated with the latest fetched snapshot */ - public get refreshSnapshotP(): Promise | undefined { - return this.refreshTracker.Promise; + public get refreshSnapshotP(): Promise | undefined { + return this.snapshotRefresher?.refreshSnapshotP; } /** @@ -288,7 +257,7 @@ export class SerializedStateManager implements IDisposable { const baseSnapshotTree: ISnapshotTree | undefined = getSnapshotTree(snapshot); const attributes = await getDocumentAttributes(this.storageAdapter, baseSnapshotTree); if (this.offlineLoadEnabled) { - this.refreshTimer?.start(); + this.snapshotRefresher?.startTimer(); this.snapshotInfo = { snapshot, snapshotSequenceNumber: attributes.sequenceNumber, @@ -326,61 +295,19 @@ export class SerializedStateManager implements IDisposable { snapshot, snapshotSequenceNumber: attributes.sequenceNumber, }; - this.tryRefreshSnapshot(); + this.snapshotRefresher?.tryRefreshSnapshot(); } return { snapshot, version: undefined, attributes }; } } - private tryRefreshSnapshot(): void { - if ( - this.#snapshotRefreshEnabled && - !this.#disposed && - !this.refreshTracker.hasPromise && - this.latestSnapshot === undefined - ) { - // Don't block on the refresh snapshot call - it is for the next time we serialize, not booting this incarnation - this.refreshTracker.setPromise(this.refreshLatestSnapshot(this.supportGetSnapshotApi())); - } - } - /** - * Fetch the latest snapshot for the container, including delay-loaded groupIds if pendingLocalState was provided and contained any groupIds. - * Note that this will update the StorageAdapter's cached snapshots for the groupIds (if present) - * - * @param supportGetSnapshotApi - a boolean indicating whether to use the fetchISnapshot or fetchISnapshotTree (must be true to fetch by groupIds) + * Handles the snapshotRefreshed event from SnapshotRefresher. + * Decides whether to accept the new snapshot based on processed ops. */ - private async refreshLatestSnapshot(supportGetSnapshotApi: boolean): Promise { - this.latestSnapshot = await getLatestSnapshotInfo( - this.mc, - this.storageAdapter, - supportGetSnapshotApi, - ); - - if (this.#disposed) { - return -1; - } - - // These are loading groupIds that the containerRuntime has requested over its lifetime. - // We will fetch the latest snapshot for the groupIds, which will update storageAdapter.loadedGroupIdSnapshots's cache - const downloadedGroupIds = Object.keys(this.storageAdapter.loadedGroupIdSnapshots); - if (supportGetSnapshotApi && downloadedGroupIds.length > 0) { - assert( - this.storageAdapter.getSnapshot !== undefined, - 0x972 /* getSnapshot should exist */, - ); - // (This is a separate network call from above because it requires work for storage to add a special base groupId) - const snapshot = await this.storageAdapter.getSnapshot({ - versionId: undefined, - scenarioName: "getLatestSnapshotInfo", - cacheSnapshot: false, - loadingGroupIds: downloadedGroupIds, - fetchSource: FetchSource.noCache, - }); - assert(snapshot !== undefined, 0x973 /* Snapshot should exist */); - } - - return this.updateSnapshotAndProcessedOpsMaybe(); + private handleSnapshotRefreshed(latestSnapshot: ISnapshotInfo): void { + this.latestSnapshot = latestSnapshot; + this.updateSnapshotAndProcessedOpsMaybe(); } /** @@ -415,14 +342,14 @@ export class SerializedStateManager implements IDisposable { stashedSnapshotSequenceNumber: this.snapshotInfo?.snapshotSequenceNumber, }); this.latestSnapshot = undefined; - this.refreshTimer?.restart(); + this.snapshotRefresher?.restartTimer(); } else if (snapshotSequenceNumber <= lastProcessedOpSequenceNumber) { // Snapshot seq num is between the first and last processed op. // Remove the ops that are already part of the snapshot this.processedOps.splice(0, snapshotSequenceNumber - firstProcessedOpSequenceNumber + 1); this.snapshotInfo = this.latestSnapshot; this.latestSnapshot = undefined; - this.refreshTimer?.restart(); + this.snapshotRefresher?.restartTimer(); this.mc.logger.sendTelemetryEvent({ eventName: "SnapshotRefreshed", snapshotSequenceNumber, @@ -448,7 +375,7 @@ export class SerializedStateManager implements IDisposable { snapshotSequenceNumber: snapshot.sequenceNumber ?? 0, snapshotFetchedTime: Date.now(), }; - this.refreshTimer?.start(); + this.snapshotRefresher?.startTimer(); } } diff --git a/packages/loader/container-loader/src/snapshotRefresher.ts b/packages/loader/container-loader/src/snapshotRefresher.ts new file mode 100644 index 000000000000..031d331b8280 --- /dev/null +++ b/packages/loader/container-loader/src/snapshotRefresher.ts @@ -0,0 +1,181 @@ +import type { ITelemetryBaseLogger } from "@fluidframework/core-interfaces"; +import type { IDisposable } from "@fluidframework/core-interfaces/internal"; +import { assert, Timer } from "@fluidframework/core-utils/internal"; +import { FetchSource } from "@fluidframework/driver-definitions/internal"; +import { + createChildMonitoringContext, + type MonitoringContext, +} from "@fluidframework/telemetry-utils/internal"; + +import { + getLatestSnapshotInfo, + type ISerializedStateManagerDocumentStorageService, + type ISnapshotInfo, +} from "./serializedStateManager.js"; + +class RefreshPromiseTracker { + public get hasPromise(): boolean { + return this.#promise !== undefined; + } + public get Promise(): Promise | undefined { + return this.#promise; + } + constructor(private readonly catchHandler: (error: Error) => void) {} + + #promise: Promise | undefined; + setPromise(p: Promise): void { + if (this.hasPromise) { + throw new Error("Cannot set promise while promise exists"); + } + this.#promise = p.finally(() => { + this.#promise = undefined; + }); + p.catch(this.catchHandler); + } +} + +export class SnapshotRefresher implements IDisposable { + private readonly mc: MonitoringContext; + private latestSnapshot: ISnapshotInfo | undefined; + #disposed: boolean = false; + + public get disposed(): boolean { + return this.#disposed; + } + + private readonly refreshTracker = new RefreshPromiseTracker( + // eslint-disable-next-line unicorn/consistent-function-scoping + (error) => + this.mc.logger.sendErrorEvent( + { + eventName: "RefreshLatestSnapshotFailed", + }, + error, + ), + ); + private readonly refreshTimer: Timer | undefined; + private readonly snapshotRefreshTimeoutMs: number = 60 * 60 * 24 * 1000; + readonly #snapshotRefreshEnabled: boolean; + + constructor( + subLogger: ITelemetryBaseLogger, + private readonly storageAdapter: ISerializedStateManagerDocumentStorageService, + private readonly offlineLoadEnabled: boolean, + private readonly supportGetSnapshotApi: () => boolean, + private readonly onSnapshotRefreshed: (snapshot: ISnapshotInfo) => void, + snapshotRefreshTimeoutMs?: number, + ) { + this.mc = createChildMonitoringContext({ + logger: subLogger, + namespace: "serializedStateManager", + }); + + this.snapshotRefreshTimeoutMs = snapshotRefreshTimeoutMs ?? this.snapshotRefreshTimeoutMs; + + this.#snapshotRefreshEnabled = + this.offlineLoadEnabled && + (this.mc.config.getBoolean("Fluid.Container.enableOfflineSnapshotRefresh") ?? + this.mc.config.getBoolean("Fluid.Container.enableOfflineFull")) === true; + + this.refreshTimer = this.#snapshotRefreshEnabled + ? new Timer(this.snapshotRefreshTimeoutMs, () => this.tryRefreshSnapshot()) + : undefined; + } + + public tryRefreshSnapshot(): void { + if ( + this.#snapshotRefreshEnabled && + !this.#disposed && + !this.refreshTracker.hasPromise && + this.latestSnapshot === undefined + ) { + // Don't block on the refresh snapshot call - it is for the next time we serialize, not booting this incarnation + this.refreshTracker.setPromise(this.refreshLatestSnapshot(this.supportGetSnapshotApi())); + } + } + + /** + * Fetch the latest snapshot for the container, including delay-loaded groupIds if pendingLocalState was provided and contained any groupIds. + * Note that this will update the StorageAdapter's cached snapshots for the groupIds (if present) + * + * @param supportGetSnapshotApi - a boolean indicating whether to use the fetchISnapshot or fetchISnapshotTree (must be true to fetch by groupIds) + */ + private async refreshLatestSnapshot(supportGetSnapshotApi: boolean): Promise { + this.latestSnapshot = await getLatestSnapshotInfo( + this.mc, + this.storageAdapter, + supportGetSnapshotApi, + ); + + if (this.#disposed) { + return -1; + } + + // These are loading groupIds that the containerRuntime has requested over its lifetime. + // We will fetch the latest snapshot for the groupIds, which will update storageAdapter.loadedGroupIdSnapshots's cache + const downloadedGroupIds = Object.keys(this.storageAdapter.loadedGroupIdSnapshots); + if (supportGetSnapshotApi && downloadedGroupIds.length > 0) { + assert( + this.storageAdapter.getSnapshot !== undefined, + 0x972 /* getSnapshot should exist */, + ); + // (This is a separate network call from above because it requires work for storage to add a special base groupId) + const snapshot = await this.storageAdapter.getSnapshot({ + versionId: undefined, + scenarioName: "getLatestSnapshotInfo", + cacheSnapshot: false, + loadingGroupIds: downloadedGroupIds, + fetchSource: FetchSource.noCache, + }); + assert(snapshot !== undefined, 0x973 /* Snapshot should exist */); + } + + // Notify the manager about the fetched snapshot - let it decide what to do with it + // Store the sequence number before calling the callback, as the callback may clear latestSnapshot + const snapshotSequenceNumber = this.latestSnapshot?.snapshotSequenceNumber ?? -1; + if (this.latestSnapshot !== undefined) { + this.onSnapshotRefreshed(this.latestSnapshot); + } + + this.refreshTimer?.restart(); + return snapshotSequenceNumber; + } + + /** + * Clears the latest snapshot after it's been consumed by the manager. + * This allows the next refresh cycle to proceed. + */ + public clearLatestSnapshot(): void { + this.latestSnapshot = undefined; + } + + /** + * Starts the refresh timer. + */ + public startTimer(): void { + this.refreshTimer?.start(); + } + + /** + * Restarts the refresh timer. + */ + public restartTimer(): void { + this.refreshTimer?.restart(); + } + + /** + * Gets the current refresh promise for testing purposes. + * @returns The snapshot sequence number promise, or undefined if no refresh is in progress + */ + public get refreshSnapshotP(): Promise | undefined { + return this.refreshTracker.Promise; + } + + /** + * Disposes the refresher and clears the timer. + */ + public dispose(): void { + this.#disposed = true; + this.refreshTimer?.clear(); + } +} diff --git a/packages/loader/container-loader/src/test/snapshotRefresher.spec.ts b/packages/loader/container-loader/src/test/snapshotRefresher.spec.ts new file mode 100644 index 000000000000..ebadf5aa4c3b --- /dev/null +++ b/packages/loader/container-loader/src/test/snapshotRefresher.spec.ts @@ -0,0 +1,790 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { strict as assert } from "node:assert"; + +import { stringToBuffer } from "@fluid-internal/client-utils"; +import type { ITelemetryBaseLogger } from "@fluidframework/core-interfaces"; +import { Deferred } from "@fluidframework/core-utils/internal"; +import type { + FetchSource, + ISnapshot, + ISnapshotFetchOptions, + ISnapshotTree, + IVersion, +} from "@fluidframework/driver-definitions/internal"; +import { MockLogger, mixinMonitoringContext } from "@fluidframework/telemetry-utils/internal"; +import { useFakeTimers, type SinonFakeTimers } from "sinon"; + +import type { + ISerializedStateManagerDocumentStorageService, + ISnapshotInfo, +} from "../serializedStateManager.js"; +import { SnapshotRefresher } from "../snapshotRefresher.js"; + +const snapshotTree: ISnapshotTree = { + id: "snapshot-1", + blobs: {}, + trees: { + ".protocol": { + blobs: { attributes: "attributesId-0" }, + trees: {}, + }, + ".app": { + blobs: {}, + trees: {}, + }, + }, +}; + +const initialSnapshot: ISnapshot = { + blobContents: new Map([ + [ + "attributesId-0", + stringToBuffer('{"minimumSequenceNumber" : 0, "sequenceNumber": 0}', "utf8"), + ], + ]), + latestSequenceNumber: undefined, + ops: [], + sequenceNumber: 0, + snapshotTree, + snapshotFormatV: 1, +}; + +function enableOfflineSnapshotRefresh(logger: ITelemetryBaseLogger): ITelemetryBaseLogger { + return mixinMonitoringContext(logger, { + getRawConfig: (name) => + name === "Fluid.Container.enableOfflineSnapshotRefresh" ? true : undefined, + }).logger; +} + +class MockStorageAdapter implements ISerializedStateManagerDocumentStorageService { + public readonly blobs = new Map(); + private snapshot: ISnapshotTree; + private snapshotSequenceNumber: number = 0; + public getSnapshotCallCount = 0; + public getVersionsCallCount = 0; + public getSnapshotTreeCallCount = 0; + public shouldFailGetVersions = false; + public shouldFailGetSnapshot = false; + private readonly getVersionsDeferred = new Deferred(); + private readonly getSnapshotDeferred = new Deferred(); + + constructor(snapshot: ISnapshotTree = snapshotTree, sequenceNumber: number = 0) { + this.snapshot = snapshot; + this.snapshotSequenceNumber = sequenceNumber; + this.blobs.set( + "attributesId-0", + stringToBuffer( + `{"minimumSequenceNumber" : 0, "sequenceNumber": ${sequenceNumber}}`, + "utf8", + ), + ); + } + + public cacheSnapshotBlobs(snapshotBlobs: Map): void { + for (const [key, value] of snapshotBlobs.entries()) { + this.blobs.set(key, value); + } + } + + public get loadedGroupIdSnapshots(): Record { + return {}; + } + + public async getSnapshot( + _snapshotFetchOptions?: ISnapshotFetchOptions | undefined, + ): Promise { + this.getSnapshotCallCount++; + if (this.shouldFailGetSnapshot) { + throw new Error("getSnapshot failed"); + } + return this.getSnapshotDeferred.promise; + } + + public async getSnapshotTree( + _version?: IVersion | undefined, + _scenarioName?: string | undefined, + // eslint-disable-next-line @rushstack/no-new-null + ): Promise { + this.getSnapshotTreeCallCount++; + return this.snapshot; + } + + public async getVersions( + // eslint-disable-next-line @rushstack/no-new-null + _versionId: string | null, + _count: number, + _scenarioName?: string | undefined, + _fetchSource?: FetchSource | undefined, + ): Promise { + this.getVersionsCallCount++; + if (this.shouldFailGetVersions) { + throw new Error("getVersions failed"); + } + return this.getVersionsDeferred.promise; + } + + public async readBlob(id: string): Promise { + if (!this.blobs.has(id)) { + throw new Error(`Requested blob does not exist: ${id}`); + } + return this.blobs.get(id) as ArrayBufferLike; + } + + public uploadSummary(sequenceNumber: number): void { + const attributesId = `attributesId-${sequenceNumber}`; + this.snapshot = structuredClone(this.snapshot); + this.snapshot.id = `snapshot-${sequenceNumber}`; + this.snapshot.trees[".protocol"].blobs.attributes = attributesId; + this.snapshotSequenceNumber = sequenceNumber; + this.blobs.set( + attributesId, + stringToBuffer( + `{"minimumSequenceNumber" : 0, "sequenceNumber": ${sequenceNumber}}`, + "utf8", + ), + ); + } + + public resolveGetVersions(): void { + assert(this.snapshot.id !== undefined, "snapshot.id should be defined"); + this.getVersionsDeferred.resolve([{ id: this.snapshot.id, treeId: this.snapshot.id }]); + } + + public resolveGetSnapshot(): void { + const snapshot: ISnapshot = { + blobContents: this.blobs, + latestSequenceNumber: undefined, + ops: [], + sequenceNumber: this.snapshotSequenceNumber, + snapshotTree: this.snapshot, + snapshotFormatV: 1, + }; + this.getSnapshotDeferred.resolve(snapshot); + } +} + +describe("SnapshotRefresher", () => { + let clock: SinonFakeTimers; + let mockLogger: MockLogger; + let mockStorage: MockStorageAdapter; + let refreshCallbackInvoked: boolean; + let lastRefreshedSnapshot: ISnapshotInfo | undefined; + const defaultRefreshTimeoutMs = 24 * 60 * 60 * 1000; // 24 hours + + function createRefresher( + offlineLoadEnabled: boolean = true, + supportGetSnapshotApi: () => boolean = () => true, + snapshotRefreshTimeoutMs?: number, + logger: ITelemetryBaseLogger = mockLogger, + ): SnapshotRefresher { + return new SnapshotRefresher( + logger, + mockStorage, + offlineLoadEnabled, + supportGetSnapshotApi, + (snapshot: ISnapshotInfo) => { + refreshCallbackInvoked = true; + lastRefreshedSnapshot = snapshot; + }, + snapshotRefreshTimeoutMs, + ); + } + + beforeEach(() => { + clock = useFakeTimers(); + mockLogger = new MockLogger(); + mockStorage = new MockStorageAdapter(); + refreshCallbackInvoked = false; + lastRefreshedSnapshot = undefined; + }); + + afterEach(() => { + clock.restore(); + }); + + describe("Constructor and Initialization", () => { + it("should create refresher with offline load enabled", () => { + const logger = enableOfflineSnapshotRefresh(mockLogger); + const refresher = createRefresher(true, () => true, undefined, logger); + assert.strictEqual(refresher.disposed, false, "Refresher should not be disposed"); + refresher.dispose(); + }); + + it("should create refresher with offline load disabled", () => { + const refresher = createRefresher(false); + assert.strictEqual(refresher.disposed, false, "Refresher should not be disposed"); + refresher.dispose(); + }); + + it("should use custom timeout when provided", () => { + const logger = enableOfflineSnapshotRefresh(mockLogger); + const customTimeout = 5000; + const refresher = createRefresher(true, () => true, customTimeout, logger); + refresher.startTimer(); + + // Verify timer doesn't fire before custom timeout + clock.tick(customTimeout - 1); + assert.strictEqual( + refreshCallbackInvoked, + false, + "Callback should not be invoked before timeout", + ); + + refresher.dispose(); + }); + + it("should use default timeout when not provided", () => { + const logger = enableOfflineSnapshotRefresh(mockLogger); + const refresher = createRefresher(true, () => true, undefined, logger); + refresher.startTimer(); + + // Verify timer doesn't fire before default timeout + clock.tick(defaultRefreshTimeoutMs - 1); + assert.strictEqual( + refreshCallbackInvoked, + false, + "Callback should not be invoked before timeout", + ); + + refresher.dispose(); + }); + }); + + describe("Timer Management", () => { + it("should start timer when startTimer is called", async () => { + const logger = enableOfflineSnapshotRefresh(mockLogger); + const timeout = 1000; + const refresher = createRefresher(true, () => true, timeout, logger); + + refresher.startTimer(); + + // Fast forward past timeout + clock.tick(timeout); + + // Should trigger refresh + assert.strictEqual(mockStorage.getVersionsCallCount, 1, "getVersions should be called"); + + refresher.dispose(); + }); + + it("should restart timer when restartTimer is called", async () => { + const logger = enableOfflineSnapshotRefresh(mockLogger); + const timeout = 1000; + const refresher = createRefresher(true, () => true, timeout, logger); + + refresher.startTimer(); + clock.tick(500); // Halfway through + + refresher.restartTimer(); + clock.tick(500); // Another 500ms (total 1000ms, but timer was restarted) + + // Should not have triggered yet + assert.strictEqual( + mockStorage.getVersionsCallCount, + 0, + "getVersions should not be called yet", + ); + + clock.tick(500); // Now we reach the restarted timeout + + // Should trigger refresh + assert.strictEqual(mockStorage.getVersionsCallCount, 1, "getVersions should be called"); + + refresher.dispose(); + }); + + it("should not trigger refresh when offline load is disabled", () => { + const timeout = 1000; + const refresher = createRefresher(false, () => true, timeout); + + refresher.startTimer(); + clock.tick(timeout); + + assert.strictEqual( + mockStorage.getVersionsCallCount, + 0, + "getVersions should not be called when offline load is disabled", + ); + + refresher.dispose(); + }); + + it("should not trigger refresh when snapshot refresh is not enabled", () => { + const timeout = 1000; + const refresher = createRefresher(true, () => true, timeout); // No config enabled + + refresher.startTimer(); + clock.tick(timeout); + + assert.strictEqual( + mockStorage.getVersionsCallCount, + 0, + "getVersions should not be called when snapshot refresh is not enabled", + ); + + refresher.dispose(); + }); + }); + + describe("tryRefreshSnapshot", () => { + it("should trigger refresh manually", async () => { + const logger = enableOfflineSnapshotRefresh(mockLogger); + const refresher = createRefresher(true, () => true, undefined, logger); + + refresher.tryRefreshSnapshot(); + + assert.strictEqual( + mockStorage.getVersionsCallCount, + 1, + "getVersions should be called once", + ); + + refresher.dispose(); + }); + + it("should not trigger refresh if already in progress", async () => { + const logger = enableOfflineSnapshotRefresh(mockLogger); + const refresher = createRefresher(true, () => true, undefined, logger); + + refresher.tryRefreshSnapshot(); + refresher.tryRefreshSnapshot(); // Try again while first is in progress + + assert.strictEqual( + mockStorage.getVersionsCallCount, + 1, + "getVersions should be called only once", + ); + + refresher.dispose(); + }); + + it("should not trigger refresh if disposed", async () => { + const logger = enableOfflineSnapshotRefresh(mockLogger); + const refresher = createRefresher(true, () => true, undefined, logger); + + refresher.dispose(); + refresher.tryRefreshSnapshot(); + + assert.strictEqual( + mockStorage.getVersionsCallCount, + 0, + "getVersions should not be called after disposal", + ); + }); + + it("should trigger refresh again after previous completes", async () => { + const logger = enableOfflineSnapshotRefresh(mockLogger); + const refresher = createRefresher(true, () => true, undefined, logger); + + // First refresh + refresher.tryRefreshSnapshot(); + mockStorage.resolveGetVersions(); + mockStorage.resolveGetSnapshot(); + + await refresher.refreshSnapshotP; + + // Clear and try again + refresher.clearLatestSnapshot(); + refresher.tryRefreshSnapshot(); + + assert.strictEqual( + mockStorage.getVersionsCallCount, + 2, + "getVersions should be called twice", + ); + + refresher.dispose(); + }); + }); + + describe("Snapshot Refresh Flow", () => { + it("should fetch snapshot using getSnapshot API when supported", async () => { + const logger = enableOfflineSnapshotRefresh(mockLogger); + const refresher = createRefresher(true, () => true, undefined, logger); + + mockStorage.uploadSummary(10); + + refresher.tryRefreshSnapshot(); + mockStorage.resolveGetVersions(); + mockStorage.resolveGetSnapshot(); + + await refresher.refreshSnapshotP; + + assert.strictEqual(mockStorage.getSnapshotCallCount, 1, "getSnapshot should be called"); + assert.strictEqual( + mockStorage.getSnapshotTreeCallCount, + 0, + "getSnapshotTree should not be called", + ); + assert.strictEqual(refreshCallbackInvoked, true, "Refresh callback should be invoked"); + assert.strictEqual( + lastRefreshedSnapshot?.snapshotSequenceNumber, + 10, + "Snapshot sequence number should be 10", + ); + + refresher.dispose(); + }); + + it("should fetch snapshot using getSnapshotTree API when getSnapshot not supported", async () => { + const logger = enableOfflineSnapshotRefresh(mockLogger); + const refresher = createRefresher(true, () => false, undefined, logger); + + mockStorage.uploadSummary(10); + + refresher.tryRefreshSnapshot(); + mockStorage.resolveGetVersions(); + + await refresher.refreshSnapshotP; + + assert.strictEqual( + mockStorage.getSnapshotCallCount, + 0, + "getSnapshot should not be called", + ); + assert.strictEqual( + mockStorage.getSnapshotTreeCallCount, + 1, + "getSnapshotTree should be called", + ); + assert.strictEqual(refreshCallbackInvoked, true, "Refresh callback should be invoked"); + assert.strictEqual( + lastRefreshedSnapshot?.snapshotSequenceNumber, + 10, + "Snapshot sequence number should be 10", + ); + + refresher.dispose(); + }); + + it("should handle refresh error and log telemetry", async () => { + const logger = enableOfflineSnapshotRefresh(mockLogger); + const refresher = createRefresher(true, () => true, undefined, logger); + + mockStorage.shouldFailGetVersions = true; + + refresher.tryRefreshSnapshot(); + + // Reject the promise to trigger error + try { + await refresher.refreshSnapshotP; + } catch { + // Expected to fail + } + + // Wait a tick for the error handler to execute + await new Promise((resolve) => setTimeout(resolve, 0)); + + mockLogger.assertMatch([ + { + eventName: "RefreshLatestSnapshotFailed", + error: "getVersions failed", + }, + ]); + + refresher.dispose(); + }); + + it("should restart timer after successful refresh", async () => { + const logger = enableOfflineSnapshotRefresh(mockLogger); + const timeout = 1000; + const refresher = createRefresher(true, () => true, timeout, logger); + + mockStorage.uploadSummary(10); + + refresher.tryRefreshSnapshot(); + mockStorage.resolveGetVersions(); + mockStorage.resolveGetSnapshot(); + + await refresher.refreshSnapshotP; + + assert.strictEqual(refreshCallbackInvoked, true, "Refresh callback should be invoked"); + + // Clear and upload new snapshot for next refresh + refreshCallbackInvoked = false; + refresher.clearLatestSnapshot(); + mockStorage.uploadSummary(20); + + // Advance timer to trigger automatic refresh + clock.tick(timeout); + + // Should trigger another refresh + assert.strictEqual( + mockStorage.getVersionsCallCount, + 2, + "getVersions should be called twice", + ); + + refresher.dispose(); + }); + + it("should not invoke callback if disposed during refresh", async () => { + const logger = enableOfflineSnapshotRefresh(mockLogger); + const refresher = createRefresher(true, () => true, undefined, logger); + + mockStorage.uploadSummary(10); + + refresher.tryRefreshSnapshot(); + + // Dispose before resolving + refresher.dispose(); + + mockStorage.resolveGetVersions(); + mockStorage.resolveGetSnapshot(); + + await refresher.refreshSnapshotP; + + assert.strictEqual( + refreshCallbackInvoked, + false, + "Refresh callback should not be invoked after disposal", + ); + }); + + it("should return -1 from refreshSnapshotP when disposed during refresh", async () => { + const logger = enableOfflineSnapshotRefresh(mockLogger); + const refresher = createRefresher(true, () => true, undefined, logger); + + mockStorage.uploadSummary(10); + + refresher.tryRefreshSnapshot(); + + // Dispose before resolving + refresher.dispose(); + + mockStorage.resolveGetVersions(); + mockStorage.resolveGetSnapshot(); + + const result = await refresher.refreshSnapshotP; + + assert.strictEqual(result, -1, "Should return -1 when disposed"); + }); + }); + + describe("clearLatestSnapshot", () => { + it("should clear latest snapshot and allow new refresh", async () => { + const logger = enableOfflineSnapshotRefresh(mockLogger); + const refresher = createRefresher(true, () => true, undefined, logger); + + mockStorage.uploadSummary(10); + + // First refresh + refresher.tryRefreshSnapshot(); + mockStorage.resolveGetVersions(); + mockStorage.resolveGetSnapshot(); + + await refresher.refreshSnapshotP; + + assert.strictEqual(refreshCallbackInvoked, true, "Refresh callback should be invoked"); + + // Try to refresh again without clearing + refreshCallbackInvoked = false; + refresher.tryRefreshSnapshot(); + + assert.strictEqual( + mockStorage.getVersionsCallCount, + 1, + "Should not refresh again with snapshot cached", + ); + + // Now clear and try again + refresher.clearLatestSnapshot(); + refresher.tryRefreshSnapshot(); + + assert.strictEqual( + mockStorage.getVersionsCallCount, + 2, + "Should refresh after clearing snapshot", + ); + + refresher.dispose(); + }); + }); + + describe("Disposal", () => { + it("should be disposable", () => { + const refresher = createRefresher(); + assert.strictEqual(refresher.disposed, false, "Should not be disposed initially"); + + refresher.dispose(); + assert.strictEqual(refresher.disposed, true, "Should be disposed after calling dispose"); + }); + + it("should clear timer on disposal", async () => { + const logger = enableOfflineSnapshotRefresh(mockLogger); + const timeout = 1000; + const refresher = createRefresher(true, () => true, timeout, logger); + + refresher.startTimer(); + refresher.dispose(); + + clock.tick(timeout); + + assert.strictEqual( + mockStorage.getVersionsCallCount, + 0, + "Timer should not fire after disposal", + ); + }); + + it("should be safe to dispose multiple times", () => { + const refresher = createRefresher(); + + refresher.dispose(); + refresher.dispose(); + refresher.dispose(); + + assert.strictEqual(refresher.disposed, true, "Should remain disposed"); + }); + }); + + describe("refreshSnapshotP promise for testing", () => { + it("should return undefined when no refresh is in progress", () => { + const refresher = createRefresher(); + + assert.strictEqual( + refresher.refreshSnapshotP, + undefined, + "Should return undefined when no refresh in progress", + ); + + refresher.dispose(); + }); + + it("should return promise when refresh is in progress", () => { + const logger = enableOfflineSnapshotRefresh(mockLogger); + const refresher = createRefresher(true, () => true, undefined, logger); + + refresher.tryRefreshSnapshot(); + + assert.notStrictEqual( + refresher.refreshSnapshotP, + undefined, + "Should return promise when refresh in progress", + ); + + refresher.dispose(); + }); + + it("should return undefined after refresh completes", async () => { + const logger = enableOfflineSnapshotRefresh(mockLogger); + const refresher = createRefresher(true, () => true, undefined, logger); + + mockStorage.uploadSummary(10); + + refresher.tryRefreshSnapshot(); + mockStorage.resolveGetVersions(); + mockStorage.resolveGetSnapshot(); + + await refresher.refreshSnapshotP; + + assert.strictEqual( + refresher.refreshSnapshotP, + undefined, + "Should return undefined after refresh completes", + ); + + refresher.dispose(); + }); + + it("should return snapshot sequence number from promise", async () => { + const logger = enableOfflineSnapshotRefresh(mockLogger); + const refresher = createRefresher(true, () => true, undefined, logger); + + mockStorage.uploadSummary(42); + + refresher.tryRefreshSnapshot(); + mockStorage.resolveGetVersions(); + mockStorage.resolveGetSnapshot(); + + const sequenceNumber = await refresher.refreshSnapshotP; + + assert.strictEqual(sequenceNumber, 42, "Should return snapshot sequence number"); + + refresher.dispose(); + }); + }); + + describe("Group ID Snapshots", () => { + it("should fetch group ID snapshots when available and getSnapshot API is supported", async () => { + const logger = enableOfflineSnapshotRefresh(mockLogger); + const mockStorageWithGroupIds = new MockStorageAdapter(); + + // Add group ID snapshots + Object.defineProperty(mockStorageWithGroupIds, "loadedGroupIdSnapshots", { + value: { "group1": initialSnapshot, "group2": initialSnapshot }, + writable: false, + }); + + const refresher = new SnapshotRefresher( + logger, + mockStorageWithGroupIds, + true, + () => true, // getSnapshot API supported + (snapshot: ISnapshotInfo) => { + refreshCallbackInvoked = true; + lastRefreshedSnapshot = snapshot; + }, + ); + + mockStorageWithGroupIds.uploadSummary(10); + + refresher.tryRefreshSnapshot(); + mockStorageWithGroupIds.resolveGetVersions(); + mockStorageWithGroupIds.resolveGetSnapshot(); + + await refresher.refreshSnapshotP; + + // Should call getSnapshot twice: once for main snapshot, once for group IDs + assert.strictEqual( + mockStorageWithGroupIds.getSnapshotCallCount, + 2, + "getSnapshot should be called twice (main + groupIds)", + ); + + refresher.dispose(); + }); + + it("should not fetch group ID snapshots when getSnapshot API is not supported", async () => { + const logger = enableOfflineSnapshotRefresh(mockLogger); + const mockStorageWithGroupIds = new MockStorageAdapter(); + + // Add group ID snapshots + Object.defineProperty(mockStorageWithGroupIds, "loadedGroupIdSnapshots", { + value: { "group1": initialSnapshot, "group2": initialSnapshot }, + writable: false, + }); + + const refresher = new SnapshotRefresher( + logger, + mockStorageWithGroupIds, + true, + () => false, // getSnapshot API not supported + (snapshot: ISnapshotInfo) => { + refreshCallbackInvoked = true; + lastRefreshedSnapshot = snapshot; + }, + ); + + mockStorageWithGroupIds.uploadSummary(10); + + refresher.tryRefreshSnapshot(); + mockStorageWithGroupIds.resolveGetVersions(); + + await refresher.refreshSnapshotP; + + // Should not call getSnapshot at all (using getSnapshotTree instead) + assert.strictEqual( + mockStorageWithGroupIds.getSnapshotCallCount, + 0, + "getSnapshot should not be called when API not supported", + ); + + refresher.dispose(); + }); + }); +}); From 1aff3efe88446e209efd4b2f1395bb2c9082055b Mon Sep 17 00:00:00 2001 From: Daniel Madrid <105010181+dannimad@users.noreply.github.com> Date: Tue, 23 Dec 2025 12:50:16 -0600 Subject: [PATCH 2/8] test fixes --- .../src/serializedStateManager.ts | 9 +++--- .../container-loader/src/snapshotRefresher.ts | 11 +++---- .../src/test/snapshotRefresher.spec.ts | 29 ++++++++++--------- 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/packages/loader/container-loader/src/serializedStateManager.ts b/packages/loader/container-loader/src/serializedStateManager.ts index 44ee092f48cd..2725a1bd1106 100644 --- a/packages/loader/container-loader/src/serializedStateManager.ts +++ b/packages/loader/container-loader/src/serializedStateManager.ts @@ -304,10 +304,11 @@ export class SerializedStateManager implements IDisposable { /** * Handles the snapshotRefreshed event from SnapshotRefresher. * Decides whether to accept the new snapshot based on processed ops. + * @returns The snapshot sequence number if updated, -1 otherwise */ - private handleSnapshotRefreshed(latestSnapshot: ISnapshotInfo): void { + private handleSnapshotRefreshed(latestSnapshot: ISnapshotInfo): number { this.latestSnapshot = latestSnapshot; - this.updateSnapshotAndProcessedOpsMaybe(); + return this.updateSnapshotAndProcessedOpsMaybe(); } /** @@ -342,14 +343,14 @@ export class SerializedStateManager implements IDisposable { stashedSnapshotSequenceNumber: this.snapshotInfo?.snapshotSequenceNumber, }); this.latestSnapshot = undefined; - this.snapshotRefresher?.restartTimer(); + this.snapshotRefresher?.clearLatestSnapshot(); } else if (snapshotSequenceNumber <= lastProcessedOpSequenceNumber) { // Snapshot seq num is between the first and last processed op. // Remove the ops that are already part of the snapshot this.processedOps.splice(0, snapshotSequenceNumber - firstProcessedOpSequenceNumber + 1); this.snapshotInfo = this.latestSnapshot; this.latestSnapshot = undefined; - this.snapshotRefresher?.restartTimer(); + this.snapshotRefresher?.clearLatestSnapshot(); this.mc.logger.sendTelemetryEvent({ eventName: "SnapshotRefreshed", snapshotSequenceNumber, diff --git a/packages/loader/container-loader/src/snapshotRefresher.ts b/packages/loader/container-loader/src/snapshotRefresher.ts index 031d331b8280..6e54b63f2a00 100644 --- a/packages/loader/container-loader/src/snapshotRefresher.ts +++ b/packages/loader/container-loader/src/snapshotRefresher.ts @@ -62,7 +62,7 @@ export class SnapshotRefresher implements IDisposable { private readonly storageAdapter: ISerializedStateManagerDocumentStorageService, private readonly offlineLoadEnabled: boolean, private readonly supportGetSnapshotApi: () => boolean, - private readonly onSnapshotRefreshed: (snapshot: ISnapshotInfo) => void, + private readonly onSnapshotRefreshed: (snapshot: ISnapshotInfo) => number, snapshotRefreshTimeoutMs?: number, ) { this.mc = createChildMonitoringContext({ @@ -131,14 +131,11 @@ export class SnapshotRefresher implements IDisposable { } // Notify the manager about the fetched snapshot - let it decide what to do with it - // Store the sequence number before calling the callback, as the callback may clear latestSnapshot - const snapshotSequenceNumber = this.latestSnapshot?.snapshotSequenceNumber ?? -1; - if (this.latestSnapshot !== undefined) { - this.onSnapshotRefreshed(this.latestSnapshot); - } + const result = + this.latestSnapshot !== undefined ? this.onSnapshotRefreshed(this.latestSnapshot) : -1; this.refreshTimer?.restart(); - return snapshotSequenceNumber; + return result; } /** diff --git a/packages/loader/container-loader/src/test/snapshotRefresher.spec.ts b/packages/loader/container-loader/src/test/snapshotRefresher.spec.ts index ebadf5aa4c3b..690abedda409 100644 --- a/packages/loader/container-loader/src/test/snapshotRefresher.spec.ts +++ b/packages/loader/container-loader/src/test/snapshotRefresher.spec.ts @@ -186,9 +186,10 @@ describe("SnapshotRefresher", () => { mockStorage, offlineLoadEnabled, supportGetSnapshotApi, - (snapshot: ISnapshotInfo) => { + (snapshot: ISnapshotInfo): number => { refreshCallbackInvoked = true; lastRefreshedSnapshot = snapshot; + return snapshot.snapshotSequenceNumber; }, snapshotRefreshTimeoutMs, ); @@ -469,19 +470,19 @@ describe("SnapshotRefresher", () => { refresher.tryRefreshSnapshot(); - // Reject the promise to trigger error - try { - await refresher.refreshSnapshotP; - } catch { - // Expected to fail - } + // The promise will reject, but the error handler will catch it internally + // We just need to wait for the promise to settle + const promise = refresher.refreshSnapshotP; + assert(promise !== undefined, "Promise should exist"); - // Wait a tick for the error handler to execute - await new Promise((resolve) => setTimeout(resolve, 0)); + // Wait a bit for the error to propagate through the error handler + await Promise.race([promise, new Promise((resolve) => setTimeout(resolve, 100))]); + clock.tick(100); - mockLogger.assertMatch([ + // The error is logged as a cancel event by PerformanceEvent.timedExecAsync + mockLogger.assertMatchAny([ { - eventName: "RefreshLatestSnapshotFailed", + eventName: "serializedStateManager:GetLatestSnapshotInfo_cancel", error: "getVersions failed", }, ]); @@ -725,9 +726,10 @@ describe("SnapshotRefresher", () => { mockStorageWithGroupIds, true, () => true, // getSnapshot API supported - (snapshot: ISnapshotInfo) => { + (snapshot: ISnapshotInfo): number => { refreshCallbackInvoked = true; lastRefreshedSnapshot = snapshot; + return snapshot.snapshotSequenceNumber; }, ); @@ -764,9 +766,10 @@ describe("SnapshotRefresher", () => { mockStorageWithGroupIds, true, () => false, // getSnapshot API not supported - (snapshot: ISnapshotInfo) => { + (snapshot: ISnapshotInfo): number => { refreshCallbackInvoked = true; lastRefreshedSnapshot = snapshot; + return snapshot.snapshotSequenceNumber; }, ); From 3dcf203d7cdfe5b10f42893aa9e60f378e28ed02 Mon Sep 17 00:00:00 2001 From: Daniel Madrid <105010181+dannimad@users.noreply.github.com> Date: Tue, 30 Dec 2025 12:43:20 -0600 Subject: [PATCH 3/8] nit --- packages/loader/container-loader/src/snapshotRefresher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/loader/container-loader/src/snapshotRefresher.ts b/packages/loader/container-loader/src/snapshotRefresher.ts index 6e54b63f2a00..e47a5803d16e 100644 --- a/packages/loader/container-loader/src/snapshotRefresher.ts +++ b/packages/loader/container-loader/src/snapshotRefresher.ts @@ -132,7 +132,7 @@ export class SnapshotRefresher implements IDisposable { // Notify the manager about the fetched snapshot - let it decide what to do with it const result = - this.latestSnapshot !== undefined ? this.onSnapshotRefreshed(this.latestSnapshot) : -1; + this.latestSnapshot === undefined ? -1 : this.onSnapshotRefreshed(this.latestSnapshot); this.refreshTimer?.restart(); return result; From 92413bb61db21d79e58ee81f3ead1fad7df1b0f1 Mon Sep 17 00:00:00 2001 From: Daniel Madrid <105010181+dannimad@users.noreply.github.com> Date: Tue, 30 Dec 2025 12:52:54 -0600 Subject: [PATCH 4/8] Update packages/loader/container-loader/src/snapshotRefresher.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../container-loader/src/snapshotRefresher.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/loader/container-loader/src/snapshotRefresher.ts b/packages/loader/container-loader/src/snapshotRefresher.ts index e47a5803d16e..dd19df8d1d0d 100644 --- a/packages/loader/container-loader/src/snapshotRefresher.ts +++ b/packages/loader/container-loader/src/snapshotRefresher.ts @@ -34,6 +34,22 @@ class RefreshPromiseTracker { } } +/** + * Manages periodic refresh of the latest snapshot for a document. + * + * `SnapshotRefresher` polls the storage service for the most recent snapshot and, when a newer + * snapshot is discovered, invokes the provided `onSnapshotRefreshed` callback with the updated + * snapshot metadata. It is responsible for: + * + * - Tracking the most recent snapshot that has been observed. + * - Scheduling and managing refresh attempts via an internal timer. + * - Emitting telemetry for successful and failed refresh attempts. + * + * The refresh behavior can be configured via constructor arguments, including whether offline + * loading and the `getSnapshot` API are supported, as well as the refresh timeout. Callers + * should dispose this instance when snapshot refresh is no longer needed to stop any pending + * timers and prevent further refresh attempts. + */ export class SnapshotRefresher implements IDisposable { private readonly mc: MonitoringContext; private latestSnapshot: ISnapshotInfo | undefined; From 522e1c5796d4a192a2785a51d13930bfe0235ddb Mon Sep 17 00:00:00 2001 From: Daniel Madrid <105010181+dannimad@users.noreply.github.com> Date: Tue, 30 Dec 2025 12:54:06 -0600 Subject: [PATCH 5/8] Update packages/loader/container-loader/src/snapshotRefresher.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/loader/container-loader/src/snapshotRefresher.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/loader/container-loader/src/snapshotRefresher.ts b/packages/loader/container-loader/src/snapshotRefresher.ts index dd19df8d1d0d..f980c50b2253 100644 --- a/packages/loader/container-loader/src/snapshotRefresher.ts +++ b/packages/loader/container-loader/src/snapshotRefresher.ts @@ -25,7 +25,9 @@ class RefreshPromiseTracker { #promise: Promise | undefined; setPromise(p: Promise): void { if (this.hasPromise) { - throw new Error("Cannot set promise while promise exists"); + throw new Error( + "Cannot start new snapshot refresh while a refresh is already in progress", + ); } this.#promise = p.finally(() => { this.#promise = undefined; From 1c77761ec68f349c72a228ddf81cda3a39aff772 Mon Sep 17 00:00:00 2001 From: Daniel Madrid <105010181+dannimad@users.noreply.github.com> Date: Tue, 30 Dec 2025 13:01:59 -0600 Subject: [PATCH 6/8] header --- packages/loader/container-loader/src/snapshotRefresher.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/loader/container-loader/src/snapshotRefresher.ts b/packages/loader/container-loader/src/snapshotRefresher.ts index f980c50b2253..6ee6a9c0a4f7 100644 --- a/packages/loader/container-loader/src/snapshotRefresher.ts +++ b/packages/loader/container-loader/src/snapshotRefresher.ts @@ -1,3 +1,8 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + import type { ITelemetryBaseLogger } from "@fluidframework/core-interfaces"; import type { IDisposable } from "@fluidframework/core-interfaces/internal"; import { assert, Timer } from "@fluidframework/core-utils/internal"; From 04c2ff1adb963be8b902293c2ac41649869b87b8 Mon Sep 17 00:00:00 2001 From: Daniel Madrid <105010181+dannimad@users.noreply.github.com> Date: Wed, 31 Dec 2025 15:46:57 -0600 Subject: [PATCH 7/8] fix test --- .../src/test/data-virtualization/groupIdOffline.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/test/test-end-to-end-tests/src/test/data-virtualization/groupIdOffline.spec.ts b/packages/test/test-end-to-end-tests/src/test/data-virtualization/groupIdOffline.spec.ts index ccc90254f6d5..2d8caa1d37fe 100644 --- a/packages/test/test-end-to-end-tests/src/test/data-virtualization/groupIdOffline.spec.ts +++ b/packages/test/test-end-to-end-tests/src/test/data-virtualization/groupIdOffline.spec.ts @@ -347,13 +347,15 @@ describeCompat("GroupId offline", "NoCompat", (getTestObjectProvider, apis) => { container2 as unknown as { // See SerializedStateManager class in container-loader package serializedStateManager: { - refreshLatestSnapshot: (supportGetSnapshotApi: boolean) => Promise; + snapshotRefresher: { + refreshLatestSnapshot: (supportGetSnapshotApi: boolean) => Promise; + }; }; } ).serializedStateManager; clearCacheIfOdsp(provider, persistedCache); - await serializedStateManager.refreshLatestSnapshot(true); + await serializedStateManager.snapshotRefresher.refreshLatestSnapshot(true); // Update the latestSequenceNumber so that the reference sequence number is beyond the snapshot await provider.ensureSynchronized(); From 95bbd731739dc63d94299436ae6c71d32b1f61a1 Mon Sep 17 00:00:00 2001 From: Daniel Madrid <105010181+dannimad@users.noreply.github.com> Date: Tue, 6 Jan 2026 09:42:40 -0600 Subject: [PATCH 8/8] nit --- packages/loader/container-loader/src/snapshotRefresher.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/loader/container-loader/src/snapshotRefresher.ts b/packages/loader/container-loader/src/snapshotRefresher.ts index 6ee6a9c0a4f7..66d880429456 100644 --- a/packages/loader/container-loader/src/snapshotRefresher.ts +++ b/packages/loader/container-loader/src/snapshotRefresher.ts @@ -22,7 +22,7 @@ class RefreshPromiseTracker { public get hasPromise(): boolean { return this.#promise !== undefined; } - public get Promise(): Promise | undefined { + public get promise(): Promise | undefined { return this.#promise; } constructor(private readonly catchHandler: (error: Error) => void) {} @@ -188,7 +188,7 @@ export class SnapshotRefresher implements IDisposable { * @returns The snapshot sequence number promise, or undefined if no refresh is in progress */ public get refreshSnapshotP(): Promise | undefined { - return this.refreshTracker.Promise; + return this.refreshTracker.promise; } /**