1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 22:03:36 +00:00

[PM-21090] Vault - Repeated Syncs (#14740)

* move `fullSync` contents to private methods in prep to storing the respective promise

* store in-flight sync so multiple calls to the sync service are avoided

* Revert "store in-flight sync so multiple calls to the sync service are avoided"

This reverts commit 233c8e9d4b.

* Revert "move `fullSync` contents to private methods in prep to storing the respective promise"

This reverts commit 3f686ac6a4.

* store inflight API calls for sync service

- This avoids duplicate network requests in a relatively short amount of time but still allows consumers to call `fullSync` if needed

* add debug log for duplicate sync
This commit is contained in:
Nick Krantz
2025-05-27 08:24:53 -05:00
committed by GitHub
parent 45f2104fd8
commit 888e2031a7
2 changed files with 82 additions and 15 deletions

View File

@@ -130,12 +130,7 @@ describe("DefaultSyncService", () => {
const user1 = "user1" as UserId; const user1 = "user1" as UserId;
describe("fullSync", () => { const emptySyncResponse = new SyncResponse({
beforeEach(() => {
accountService.activeAccount$ = of({ id: user1 } as Account);
Matrix.autoMockMethod(authService.authStatusFor$, () => of(AuthenticationStatus.Unlocked));
apiService.getSync.mockResolvedValue(
new SyncResponse({
profile: { profile: {
id: user1, id: user1,
}, },
@@ -145,8 +140,13 @@ describe("DefaultSyncService", () => {
sends: [], sends: [],
domains: [], domains: [],
policies: [], policies: [],
}), });
);
describe("fullSync", () => {
beforeEach(() => {
accountService.activeAccount$ = of({ id: user1 } as Account);
Matrix.autoMockMethod(authService.authStatusFor$, () => of(AuthenticationStatus.Unlocked));
apiService.getSync.mockResolvedValue(emptySyncResponse);
Matrix.autoMockMethod(userDecryptionOptionsService.userDecryptionOptionsById$, () => Matrix.autoMockMethod(userDecryptionOptionsService.userDecryptionOptionsById$, () =>
of({ hasMasterPassword: true } satisfies UserDecryptionOptions), of({ hasMasterPassword: true } satisfies UserDecryptionOptions),
); );
@@ -201,5 +201,44 @@ describe("DefaultSyncService", () => {
expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1);
expect(apiService.getSync).toHaveBeenCalledTimes(1); expect(apiService.getSync).toHaveBeenCalledTimes(1);
}); });
describe("in-flight syncs", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it("does not call getSync when one is already in progress", async () => {
const fullSyncPromises = [sut.fullSync(true), sut.fullSync(false), sut.fullSync(false)];
jest.advanceTimersByTime(100);
await Promise.all(fullSyncPromises);
expect(apiService.getSync).toHaveBeenCalledTimes(1);
});
it("does not call refreshIdentityToken when one is already in progress", async () => {
const fullSyncPromises = [sut.fullSync(true), sut.fullSync(false), sut.fullSync(false)];
jest.advanceTimersByTime(100);
await Promise.all(fullSyncPromises);
expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1);
});
it("resets the in-flight properties when the complete", async () => {
const fullSyncPromises = [sut.fullSync(true), sut.fullSync(true)];
await Promise.all(fullSyncPromises);
expect(sut["inFlightApiCalls"].refreshToken).toBeNull();
expect(sut["inFlightApiCalls"].sync).toBeNull();
});
});
}); });
}); });

View File

@@ -58,11 +58,21 @@ import { MessageSender } from "../messaging";
import { StateProvider } from "../state"; import { StateProvider } from "../state";
import { CoreSyncService } from "./core-sync.service"; import { CoreSyncService } from "./core-sync.service";
import { SyncResponse } from "./sync.response";
import { SyncOptions } from "./sync.service"; import { SyncOptions } from "./sync.service";
export class DefaultSyncService extends CoreSyncService { export class DefaultSyncService extends CoreSyncService {
syncInProgress = false; syncInProgress = false;
/** The promises associated with any in-flight api calls. */
private inFlightApiCalls: {
refreshToken: Promise<void> | null;
sync: Promise<SyncResponse> | null;
} = {
refreshToken: null,
sync: null,
};
constructor( constructor(
private masterPasswordService: InternalMasterPasswordServiceAbstraction, private masterPasswordService: InternalMasterPasswordServiceAbstraction,
accountService: AccountService, accountService: AccountService,
@@ -141,9 +151,24 @@ export class DefaultSyncService extends CoreSyncService {
try { try {
if (!skipTokenRefresh) { if (!skipTokenRefresh) {
await this.apiService.refreshIdentityToken(); // Store the promise so multiple calls to refresh the token are not made
if (this.inFlightApiCalls.refreshToken === null) {
this.inFlightApiCalls.refreshToken = this.apiService.refreshIdentityToken();
} }
const response = await this.apiService.getSync();
await this.inFlightApiCalls.refreshToken;
}
// Store the promise so multiple calls to sync are not made
if (this.inFlightApiCalls.sync === null) {
this.inFlightApiCalls.sync = this.apiService.getSync();
} else {
this.logService.debug(
"Sync: Sync network call already in progress, returning existing promise",
);
}
const response = await this.inFlightApiCalls.sync;
await this.syncProfile(response.profile); await this.syncProfile(response.profile);
await this.syncFolders(response.folders, response.profile.id); await this.syncFolders(response.folders, response.profile.id);
@@ -162,6 +187,9 @@ export class DefaultSyncService extends CoreSyncService {
} else { } else {
return this.syncCompleted(false, userId); return this.syncCompleted(false, userId);
} }
} finally {
this.inFlightApiCalls.refreshToken = null;
this.inFlightApiCalls.sync = null;
} }
} }