diff --git a/packages/services/src/Domain/Sync/SyncBackoffService.spec.ts b/packages/services/src/Domain/Sync/SyncBackoffService.spec.ts new file mode 100644 index 000000000..aea7bb3f4 --- /dev/null +++ b/packages/services/src/Domain/Sync/SyncBackoffService.spec.ts @@ -0,0 +1,55 @@ +import { AnyItemInterface } from '@standardnotes/models' +import { SyncBackoffService } from './SyncBackoffService' + +describe('SyncBackoffService', () => { + const createService = () => new SyncBackoffService() + + it('should not be in backoff if no backoff was set', () => { + const service = createService() + + expect(service.isItemInBackoff({ uuid: '123' } as jest.Mocked)).toBe(false) + }) + + it('should be in backoff if backoff was set', () => { + const service = createService() + + service.backoffItem({ uuid: '123' } as jest.Mocked) + + expect(service.isItemInBackoff({ uuid: '123' } as jest.Mocked)).toBe(true) + }) + + it('should not be in backoff if backoff expired', () => { + const service = createService() + + jest.spyOn(Date, 'now').mockReturnValueOnce(1_000_000) + + service.backoffItem({ uuid: '123' } as jest.Mocked) + + jest.spyOn(Date, 'now').mockReturnValueOnce(2_000_000) + + expect(service.isItemInBackoff({ uuid: '123' } as jest.Mocked)).toBe(false) + }) + + it('should double backoff penalty on each backoff', () => { + const service = createService() + + jest.spyOn(Date, 'now').mockReturnValueOnce(1_000_000) + + service.backoffItem({ uuid: '123' } as jest.Mocked) + + jest.spyOn(Date, 'now').mockReturnValueOnce(1_000_000) + expect(service.isItemInBackoff({ uuid: '123' } as jest.Mocked)).toBe(true) + + jest.spyOn(Date, 'now').mockReturnValueOnce(1_000_000) + service.backoffItem({ uuid: '123' } as jest.Mocked) + + jest.spyOn(Date, 'now').mockReturnValueOnce(1_001_000) + expect(service.isItemInBackoff({ uuid: '123' } as jest.Mocked)).toBe(true) + + jest.spyOn(Date, 'now').mockReturnValueOnce(1_000_000) + service.backoffItem({ uuid: '123' } as jest.Mocked) + + jest.spyOn(Date, 'now').mockReturnValueOnce(1_003_000) + expect(service.isItemInBackoff({ uuid: '123' } as jest.Mocked)).toBe(true) + }) +}) diff --git a/packages/services/src/Domain/Sync/SyncBackoffService.ts b/packages/services/src/Domain/Sync/SyncBackoffService.ts new file mode 100644 index 000000000..8a8636254 --- /dev/null +++ b/packages/services/src/Domain/Sync/SyncBackoffService.ts @@ -0,0 +1,37 @@ +import { AnyItemInterface } from '@standardnotes/models' +import { SyncBackoffServiceInterface } from './SyncBackoffServiceInterface' + +export class SyncBackoffService implements SyncBackoffServiceInterface { + private backoffPenalties: Map + private backoffStartTimestamps: Map + + constructor() { + this.backoffPenalties = new Map() + this.backoffStartTimestamps = new Map() + } + + isItemInBackoff(item: AnyItemInterface): boolean { + const backoffStartingTimestamp = this.backoffStartTimestamps.get(item.uuid) + if (!backoffStartingTimestamp) { + return false + } + + const backoffPenalty = this.backoffPenalties.get(item.uuid) + if (!backoffPenalty) { + return false + } + + const backoffEndTimestamp = backoffStartingTimestamp + backoffPenalty + + return backoffEndTimestamp > Date.now() + } + + backoffItem(item: AnyItemInterface): void { + const backoffPenalty = this.backoffPenalties.get(item.uuid) || 0 + + const newBackoffPenalty = backoffPenalty === 0 ? 1_000 : backoffPenalty * 2 + + this.backoffPenalties.set(item.uuid, newBackoffPenalty) + this.backoffStartTimestamps.set(item.uuid, Date.now()) + } +} diff --git a/packages/services/src/Domain/Sync/SyncBackoffServiceInterface.ts b/packages/services/src/Domain/Sync/SyncBackoffServiceInterface.ts new file mode 100644 index 000000000..2aab2295c --- /dev/null +++ b/packages/services/src/Domain/Sync/SyncBackoffServiceInterface.ts @@ -0,0 +1,6 @@ +import { AnyItemInterface } from '@standardnotes/models' + +export interface SyncBackoffServiceInterface { + isItemInBackoff(item: AnyItemInterface): boolean + backoffItem(item: AnyItemInterface): void +} diff --git a/packages/services/src/Domain/index.ts b/packages/services/src/Domain/index.ts index ef7fc0712..53d78c9b1 100644 --- a/packages/services/src/Domain/index.ts +++ b/packages/services/src/Domain/index.ts @@ -181,6 +181,8 @@ export * from './Subscription/AppleIAPReceipt' export * from './Subscription/SubscriptionManager' export * from './Subscription/SubscriptionManagerEvent' export * from './Subscription/SubscriptionManagerInterface' +export * from './Sync/SyncBackoffService' +export * from './Sync/SyncBackoffServiceInterface' export * from './Sync/SyncMode' export * from './Sync/SyncOpStatus' export * from './Sync/SyncOptions' diff --git a/packages/snjs/lib/Application/Dependencies/Dependencies.ts b/packages/snjs/lib/Application/Dependencies/Dependencies.ts index cd7015998..ca0288a2b 100644 --- a/packages/snjs/lib/Application/Dependencies/Dependencies.ts +++ b/packages/snjs/lib/Application/Dependencies/Dependencies.ts @@ -145,6 +145,8 @@ import { IsVaultAdmin, IsReadonlyVaultMember, DesignateSurvivor, + SyncBackoffService, + SyncBackoffServiceInterface, } from '@standardnotes/services' import { ItemManager } from '../../Services/Items/ItemManager' import { PayloadManager } from '../../Services/Payloads/PayloadManager' @@ -1351,6 +1353,10 @@ export class Dependencies { ) }) + this.factory.set(TYPES.SyncBackoffService, () => { + return new SyncBackoffService() + }) + this.factory.set(TYPES.SyncService, () => { return new SyncService( this.get(TYPES.ItemManager), @@ -1369,6 +1375,7 @@ export class Dependencies { this.get(TYPES.Logger), this.get(TYPES.WebSocketsService), this.get(TYPES.SyncFrequencyGuard), + this.get(TYPES.SyncBackoffService), this.get(TYPES.InternalEventBus), ) }) diff --git a/packages/snjs/lib/Application/Dependencies/Types.ts b/packages/snjs/lib/Application/Dependencies/Types.ts index 416bf504a..50f8e0543 100644 --- a/packages/snjs/lib/Application/Dependencies/Types.ts +++ b/packages/snjs/lib/Application/Dependencies/Types.ts @@ -30,6 +30,7 @@ export const TYPES = { SubscriptionManager: Symbol.for('SubscriptionManager'), HistoryManager: Symbol.for('HistoryManager'), SyncFrequencyGuard: Symbol.for('SyncFrequencyGuard'), + SyncBackoffService: Symbol.for('SyncBackoffService'), SyncService: Symbol.for('SyncService'), ProtectionService: Symbol.for('ProtectionService'), UserService: Symbol.for('UserService'), diff --git a/packages/snjs/lib/Services/Sync/SyncService.ts b/packages/snjs/lib/Services/Sync/SyncService.ts index 12963133d..f81672cbc 100644 --- a/packages/snjs/lib/Services/Sync/SyncService.ts +++ b/packages/snjs/lib/Services/Sync/SyncService.ts @@ -86,6 +86,7 @@ import { ApplicationSyncOptions, WebSocketsServiceEvent, WebSocketsService, + SyncBackoffServiceInterface, } from '@standardnotes/services' import { OfflineSyncResponse } from './Offline/Response' import { @@ -171,6 +172,7 @@ export class SyncService private logger: LoggerInterface, private sockets: WebSocketsService, private syncFrequencyGuard: SyncFrequencyGuardInterface, + private syncBackoffService: SyncBackoffServiceInterface, protected override internalEventBus: InternalEventBusInterface, ) { super(internalEventBus) @@ -452,7 +454,11 @@ export class SyncService } private itemsNeedingSync() { - return this.itemManager.getDirtyItems() + const dirtyItems = this.itemManager.getDirtyItems() + + const itemsWithoutBackoffPenalty = dirtyItems.filter((item) => !this.syncBackoffService.isItemInBackoff(item)) + + return itemsWithoutBackoffPenalty } public async markAllItemsAsNeedingSyncAndPersist(): Promise {