From 233c8e9d4b92d448f63e157b01c168d840c7e531 Mon Sep 17 00:00:00 2001 From: Nick Krantz Date: Mon, 12 May 2025 11:34:03 -0500 Subject: [PATCH] store in-flight sync so multiple calls to the sync service are avoided --- .../sync/default-sync.service.spec.ts | 45 +++++++++++++++++++ .../src/platform/sync/default-sync.service.ts | 14 +++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/libs/common/src/platform/sync/default-sync.service.spec.ts b/libs/common/src/platform/sync/default-sync.service.spec.ts index ded06c8be6b..96678a64a53 100644 --- a/libs/common/src/platform/sync/default-sync.service.spec.ts +++ b/libs/common/src/platform/sync/default-sync.service.spec.ts @@ -195,5 +195,50 @@ describe("DefaultSyncService", () => { expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); expect(apiService.getSync).toHaveBeenCalledTimes(1); }); + + describe("in-flight syncs", () => { + let mockFullSync: jest.SpyInstance; + + beforeEach(() => { + jest.useFakeTimers(); + + // Mock the _fullSync method so the tests can control the promise resolution + // the internal implementation isn't what is tested here. + mockFullSync = jest.spyOn(sut as any, "_fullSync").mockImplementation(() => { + return new Promise((resolve) => setTimeout(() => resolve(true), 50)); + }); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it("does not call internal sync when one is already in progress", async () => { + const fullSyncPromises = [sut.fullSync(true), sut.fullSync(false), sut.fullSync(false)]; + + jest.advanceTimersByTime(100); + + const [result1, result2, result3] = await Promise.all(fullSyncPromises); + + expect(result1).toBe(true); + expect(result2).toBe(true); + expect(result3).toBe(true); + + expect(mockFullSync).toHaveBeenCalledTimes(1); + }); + + it("resets the in-flight sync when the complete", async () => { + const fullSyncPromises = [sut.fullSync(true), sut.fullSync(false)]; + + expect(sut["inFlightSync"]).not.toBeNull(); + + jest.advanceTimersByTime(50); + + await Promise.all(fullSyncPromises); + + expect(sut["inFlightSync"]).toBeNull(); + }); + }); }); }); diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index 54c3272f109..3d8f2059f9b 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -59,6 +59,9 @@ import { SyncOptions } from "./sync.service"; export class DefaultSyncService extends CoreSyncService { syncInProgress = false; + /** The promise associated with any in-flight sync operations. When null, no sync is in-flight. */ + private inFlightSync: Promise | null = null; + constructor( private masterPasswordService: InternalMasterPasswordServiceAbstraction, accountService: AccountService, @@ -107,7 +110,16 @@ export class DefaultSyncService extends CoreSyncService { forceSync: boolean, allowThrowOnErrorOrOptions?: boolean | SyncOptions, ): Promise { - return this._fullSync(forceSync, allowThrowOnErrorOrOptions); + if (this.inFlightSync !== null) { + return this.inFlightSync; + } + + this.inFlightSync = this._fullSync(forceSync, allowThrowOnErrorOrOptions).finally(() => { + // Reset the in-flight sync promise when it completes + this.inFlightSync = null; + }); + + return this.inFlightSync; } private async needsSyncing(forceSync: boolean) {